Merge branch 'main' into fix/rocm-strix-halo-unified-memory

This commit is contained in:
Lee Jackson 2026-05-15 00:08:11 +01:00 committed by GitHub
commit 1d30fb5c21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
227 changed files with 48428 additions and 1951 deletions

View file

@ -480,6 +480,37 @@ def save_refresh_token(
conn.close()
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
"""Atomically validate-and-delete a refresh token for single-use rotation.
DELETE RETURNING fuses validate and delete into one statement so two
concurrent refresh requests cannot both consume the same token.
"""
token_hash = _hash_token(token)
now = datetime.now(timezone.utc).isoformat()
conn = get_connection()
try:
conn.execute(
"DELETE FROM refresh_tokens WHERE expires_at < ?",
(now,),
)
cur = conn.execute(
"""
DELETE FROM refresh_tokens
WHERE token_hash = ? AND expires_at >= ?
RETURNING username, is_desktop
""",
(token_hash, now),
)
row = cur.fetchone()
conn.commit()
if row is None:
return None
return row["username"], bool(row["is_desktop"])
finally:
conn.close()
def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
"""
Verify a refresh token and return the username plus desktop marker.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,127 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
RSA key pair for encrypting API keys in transit.
The frontend encrypts API keys with the server's public key before
including them in requests. The backend decrypts with its private key
before forwarding to external providers.
The key pair is generated at server startup and lives only in memory
it is regenerated on each restart. The frontend fetches the public key
via GET /api/providers/public-key on load.
"""
import base64
import hashlib
import logging
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import serialization, hashes
logger = logging.getLogger(__name__)
_private_key: rsa.RSAPrivateKey | None = None
_public_key_pem: str | None = None
_public_key_fingerprint: str | None = None
def _compute_fingerprint(pem: str) -> str:
"""SHA256 of the PEM bytes, truncated for log compactness."""
return hashlib.sha256(pem.encode("utf-8")).hexdigest()[:16]
def init_key_pair() -> None:
"""Generate an RSA-2048 key pair. Called once at server startup."""
global _private_key, _public_key_pem, _public_key_fingerprint
if _private_key is not None:
# Re-entry is suspicious — every fresh keypair invalidates all
# in-flight ciphertext encrypted against the previous public key.
# Log loudly so a regression that calls init twice is visible.
logger.warning(
"init_key_pair called again — replacing existing RSA keypair "
"(previous fingerprint=%s). Any frontend that cached the old "
"public key will start hitting decryption failures.",
_public_key_fingerprint,
)
_private_key = rsa.generate_private_key(
public_exponent = 65537,
key_size = 2048,
)
_public_key_pem = (
_private_key.public_key()
.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
_public_key_fingerprint = _compute_fingerprint(_public_key_pem)
logger.info(
"RSA key pair generated for API key encryption (fingerprint=%s)",
_public_key_fingerprint,
)
def get_public_key_fingerprint() -> str | None:
"""Short SHA256 of the current public key PEM; None before init."""
return _public_key_fingerprint
def get_public_key_pem() -> str:
"""Return the PEM-encoded public key for the frontend."""
if _public_key_pem is None:
raise RuntimeError("Key pair not initialized. Call init_key_pair() first.")
return _public_key_pem
def decrypt_api_key(encrypted_b64: str) -> str:
"""
Decrypt an API key that was encrypted with the public key.
Args:
encrypted_b64: Base64-encoded RSA-OAEP ciphertext.
Returns:
The plaintext API key string.
"""
if _private_key is None:
raise RuntimeError("Key pair not initialized. Call init_key_pair() first.")
try:
ciphertext = base64.b64decode(encrypted_b64)
except Exception as exc:
logger.warning(
"decrypt_api_key: base64 decode failed (input_len=%d, fingerprint=%s): %s: %s",
len(encrypted_b64),
_public_key_fingerprint,
type(exc).__name__,
exc,
)
raise
try:
plaintext = _private_key.decrypt(
ciphertext,
padding.OAEP(
mgf = padding.MGF1(algorithm = hashes.SHA256()),
algorithm = hashes.SHA256(),
label = None,
),
)
except Exception as exc:
# Surface enough state to distinguish key mismatch (wrong public key
# used on encrypt) from a padding/algo mismatch or corrupted bytes.
# Expected ciphertext length for RSA-2048 is exactly 256 bytes.
logger.warning(
"decrypt_api_key: RSA decrypt failed (ciphertext_len=%d, expected=256, "
"fingerprint=%s, exc=%s): %s",
len(ciphertext),
_public_key_fingerprint,
type(exc).__name__,
exc,
)
raise
return plaintext.decode("utf-8")

View file

@ -433,6 +433,8 @@ class LlamaCppBackend:
self._hf_variant: Optional[str] = None
self._is_vision: bool = False
self._healthy = False
# Set by _classify_gpu_offload after _wait_for_health.
self._gpu_offload_active: Optional[bool] = None
self._context_length: Optional[int] = None
self._effective_context_length: Optional[int] = None
self._max_context_length: Optional[int] = None
@ -956,6 +958,73 @@ class LlamaCppBackend:
logger.debug(f"torch GPU probe failed: {e}")
return []
# Free-VRAM fraction at which Studio pins the GPU directly instead
# of deferring to ``--fit on``. 5% headroom covers CUDA context +
# compute buffers; 0.90 was too conservative and dropped 91-94%
# fits to CPU offload (#5106). The fork's --fit on still catches
# the truly-too-large case.
_GPU_PIN_VRAM_FRACTION = 0.95
@staticmethod
def _windows_pip_nvidia_dll_dirs(prefix: str) -> list[str]:
"""Return DLL dirs from pip-installed CUDA wheels under
``<prefix>/Lib/site-packages/`` so llama-server.exe can load
``cudart64_X.dll`` / ``cublas64_X.dll`` without a system CUDA
toolkit. Mirrors the Linux ``nvidia/cu*/lib`` LD_LIBRARY_PATH
block, with parity for the Windows-specific wheel layouts seen
in the wild. Covered patterns:
* ``nvidia/<pkg>/bin`` -- legacy modular wheels
(``nvidia-cuda-runtime-cu12``, ``nvidia-cublas-cu12``, etc.).
* ``nvidia/<pkg>/bin/x86_64`` and ``.../bin/x64`` -- current
CUDA 13 wheel layout used by the unsuffixed
``nvidia-cuda-runtime`` / ``nvidia-cublas`` packages, which
ship under ``nvidia/cu13/bin/x86_64/`` (#5106).
* ``nvidia/<pkg>/Library/bin`` (and arch subdirs) -- conda-
style wheel repacks.
* ``torch/lib`` -- PyTorch's own CUDA-bundled Windows wheel,
which can ship ``cudart64_*.dll`` directly here instead of
as separate ``nvidia-*`` wheels. The install-side helper
``python_runtime_dirs`` in ``install_llama_prebuilt.py``
covers this path for the same reason.
Walks the tree with ``Path.iterdir`` rather than ``glob.glob``
so the resolver is safe against Windows paths containing
``[`` or ``]`` (valid in usernames; would otherwise be
interpreted as a glob character class and silently miss
existing dirs)."""
site_packages = Path(prefix) / "Lib" / "site-packages"
out: list[str] = []
seen: set[str] = set()
def _add(path: Path) -> None:
if not path.is_dir():
return
key = os.path.normcase(os.path.abspath(str(path)))
if key in seen:
return
seen.add(key)
out.append(str(path))
nvidia_root = site_packages / "nvidia"
if nvidia_root.is_dir():
for pkg_dir in nvidia_root.iterdir():
if not pkg_dir.is_dir():
continue
# Order matters for PATH search: arch-specific subdirs
# first so the explicit cudart64_X.dll location wins
# over a sibling ``bin`` that might be empty.
for sub in (
pkg_dir / "bin" / "x86_64",
pkg_dir / "bin" / "x64",
pkg_dir / "bin",
pkg_dir / "Library" / "bin" / "x86_64",
pkg_dir / "Library" / "bin" / "x64",
pkg_dir / "Library" / "bin",
):
_add(sub)
_add(site_packages / "torch" / "lib")
return out
@staticmethod
def _select_gpus(
model_size_bytes: int,
@ -964,11 +1033,11 @@ class LlamaCppBackend:
"""Pick GPU(s) for a model based on estimated VRAM and free memory.
``model_size_bytes`` should include both model weights and estimated
KV cache. The 90% threshold provides headroom for compute buffers,
CUDA context, and other runtime overhead.
KV cache. The ``_GPU_PIN_VRAM_FRACTION`` threshold provides headroom
for compute buffers, CUDA context, and other runtime overhead.
Returns (gpu_indices, use_fit):
- ([1], False) model fits on 1 GPU at 90% of free
- ([1], False) model fits on 1 GPU at the headroom threshold
- ([1, 2], False) model needs 2 GPUs
- (None, True) model too large, let --fit handle it
"""
@ -976,12 +1045,13 @@ class LlamaCppBackend:
return None, True
model_size_mib = model_size_bytes / (1024 * 1024)
usable_fraction = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
# Sort GPUs by free memory descending
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
# Try fitting on 1 GPU (90% of free memory threshold)
if ranked[0][1] * 0.90 >= model_size_mib:
# Try fitting on 1 GPU at the usable-VRAM threshold.
if ranked[0][1] * usable_fraction >= model_size_mib:
return [ranked[0][0]], False
# Try fitting on N GPUs (accumulate free memory from most-free)
@ -989,7 +1059,7 @@ class LlamaCppBackend:
selected = []
for idx, free_mib in ranked:
selected.append(idx)
cumulative += free_mib * 0.90
cumulative += free_mib * usable_fraction
if cumulative >= model_size_mib:
return sorted(selected), False
@ -1222,10 +1292,11 @@ class LlamaCppBackend:
) -> int:
"""Return the largest context length that fits in GPU VRAM.
Uses 90% of available VRAM as the budget (matching _select_gpus
threshold -- 10% reserved for compute buffers, CUDA context,
scratch space, flash-attn workspace, etc.).
If the model weights alone don't fit, returns min_ctx unchanged.
Uses 90% of available VRAM as the ctx-fit budget. Tighter than
``_GPU_PIN_VRAM_FRACTION`` on purpose: over-promising context
OOMs at runtime, while pinning conservatively just defers to
--fit on. If the weights alone don't fit, returns
``requested_ctx`` unchanged.
``kv_on_gpu`` mirrors ``--kv-offload`` (default on). When False
the KV cache lives in CPU RAM and doesn't compete with weights
@ -1971,6 +2042,7 @@ class LlamaCppBackend:
# still has valid state to publish.
effective_ctx = n_ctx if n_ctx > 0 else (self._context_length or 0)
max_available_ctx = self._context_length or effective_ctx
gpus: list[tuple[int, int]] = []
try:
model_size = self._get_gguf_size_bytes(model_path)
gpus = self._get_gpu_free_memory()
@ -2054,8 +2126,11 @@ class LlamaCppBackend:
gpu_indices, use_fit = self._select_gpus(requested_total, gpus)
# No silent shrink: effective_ctx stays == n_ctx.
else:
# Auto context: prefer fewer GPUs, cap context to fit.
# Auto context: prefer fewer GPUs, cap context
# to fit. Same headroom threshold as
# _select_gpus (#5106).
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
pin_fraction = self._GPU_PIN_VRAM_FRACTION
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
@ -2070,18 +2145,31 @@ class LlamaCppBackend:
capped, cache_type_kv, n_parallel = n_parallel
)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * 0.90:
if total_mib <= pool_mib * pin_fraction:
effective_ctx = capped
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
break
else:
# No subset can host the weights (weights alone
# exceed 90% of every pool). Per spec, default
# the UI-visible context to 4096 and let
# --fit on flex -ngl so llama-server offloads
# layers to CPU RAM.
# Native ctx doesn't fit. Drop to 4096 and
# re-check before deferring to --fit on:
# a model that overflows at 131k may pin
# comfortably with a 4096 KV cache (#5106).
effective_ctx = min(4096, effective_ctx)
if effective_ctx > 0:
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
kv = self._estimate_kv_cache_bytes(
effective_ctx,
cache_type_kv,
n_parallel = n_parallel,
)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * pin_fraction:
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
break
elif gpus:
# Can't estimate KV -- fall back to file-size-only check.
@ -2319,9 +2407,14 @@ class LlamaCppBackend:
binary_dir = str(Path(binary).parent)
if sys.platform == "win32":
# On Windows, CUDA DLLs (cublas64_12.dll, cudart64_12.dll, etc.)
# must be on PATH. Add CUDA_PATH\bin if available.
# CUDA DLLs (cudart64_X.dll, cublas64_X.dll, etc.) must
# be on PATH. Order: binary_dir, torch's pip-installed
# nvidia wheels, then a system CUDA toolkit. Pip wheels
# are the canonical source per Studio's install design
# (mirrors the Linux LD_LIBRARY_PATH block below) and
# CUDA_PATH covers users with a system toolkit. #5106.
path_dirs = [binary_dir]
path_dirs.extend(self._windows_pip_nvidia_dll_dirs(sys.prefix))
cuda_path = os.environ.get("CUDA_PATH", "")
if cuda_path:
cuda_bin = os.path.join(cuda_path, "bin")
@ -2505,12 +2598,54 @@ class LlamaCppBackend:
self._healthy = True
# Catch silent CPU fallback when GPU was intended (#5106).
self._gpu_offload_active = self._classify_gpu_offload(
gpu_indices is not None or use_fit, gpus or []
)
if self._gpu_offload_active is False:
logger.warning(
"llama-server appears to have loaded the model entirely "
"on CPU even though Studio detected at least one GPU. "
"This usually means the prebuilt binary's GPU backend "
"failed to load -- on Windows, cudart64_X.dll / "
"cublas64_X.dll could not be resolved. Reinstall the "
"Studio llama.cpp prebuilt or install a matching CUDA "
"toolkit (issue unslothai/unsloth#5106).",
)
logger.info(
f"llama-server ready on port {self._port} "
f"for model '{model_identifier}'"
)
return True
def _classify_gpu_offload(
self,
expected_gpu: bool,
detected_gpus: list[tuple[int, int]],
) -> Optional[bool]:
"""True if a GPU model buffer was allocated, False if only CPU
buffers landed despite GPU intent, None when there's no signal
(no GPU detected, no buffer-size lines, etc.)."""
if not detected_gpus or not expected_gpu:
return None
# llama-server logs one ``... model buffer size = N MiB`` line
# per backend buffer; CUDA0 / ROCm0 / Metal / Vulkan0 /
# OpenCL0 / SYCL0 are GPU, CPU / CPU_Mapped are not.
gpu_markers = ("CUDA", "ROCm", "Metal", "Vulkan", "OpenCL", "SYCL")
saw_buffer_line = False
saw_gpu_buffer = False
for line in self._stdout_lines:
if "model buffer size" not in line:
continue
saw_buffer_line = True
if any(marker in line for marker in gpu_markers):
saw_gpu_buffer = True
break
if not saw_buffer_line:
return None
return saw_gpu_buffer
def unload_model(self) -> bool:
"""Terminate the llama-server subprocess and cancel any in-flight download."""
self._cancel_event.set()

View file

@ -78,6 +78,28 @@ class MLXInferenceBackend:
model_name = config.identifier if hasattr(config, "identifier") else str(config)
is_vision = getattr(config, "is_vision", False)
# GGUF guard. GGUF models are served via llama-server in the
# parent process, NOT via mlx-lm in this MLX subprocess. The
# route at studio/backend/routes/inference.py:592 (`if config.
# is_gguf:`) is responsible for sending GGUF traffic to the
# llama-server backend before reaching the MLX orchestrator.
# If we end up here with is_gguf=True, the route's
# `detect_gguf_model_remote` returned None on its first call
# (transient HF Hub flake) but the subprocess re-detection
# succeeded. The subprocess cannot reach into the parent's
# llama-server, so all we can do is raise loudly so the caller
# gets a clear error instead of a cryptic
# "config.json does not exist" from mlx_lm.utils.load_model.
if getattr(config, "is_gguf", False):
raise RuntimeError(
f"MLXInferenceBackend cannot load GGUF model '{model_name}': "
f"GGUF models must be served by llama-server in the parent "
f"process. The /api/inference/load route should have "
f"detected this repo as GGUF before dispatching to the MLX "
f"orchestrator -- this fallback indicates a transient HF "
f"Hub failure during initial detection. Retry the request."
)
if hf_token:
import os
@ -94,11 +116,11 @@ class MLXInferenceBackend:
)
try:
from unsloth_zoo.mlx_loader import FastMLXModel
from unsloth_zoo.mlx.loader import FastMLXModel
except ImportError as e:
raise ImportError(
"Unsloth: MLX inference requires unsloth-zoo with the MLX modules "
"(unsloth_zoo.mlx_loader). Reinstall via install.sh on Apple Silicon."
"(unsloth_zoo.mlx.loader). Reinstall via install.sh on Apple Silicon."
) from e
model, tokenizer_or_processor = FastMLXModel.from_pretrained(

View file

@ -0,0 +1,287 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Static registry of supported external LLM providers.
All providers expose OpenAI-compatible /v1/chat/completions endpoints
with Bearer token authentication and SSE streaming support.
"""
import re
from typing import Any
PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
"openai": {
"display_name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"default_models": [
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"o3",
],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
# Keep the model picker scoped to the current generation. The remote
# /v1/models listing returns dozens of historical snapshots, fine-tunes
# and non-chat models (embeddings, TTS, image, moderation) that we
# never want to surface in the chat UI. Filtering here so backend
# is the single source of truth.
"model_id_allowlist": re.compile(r"^(gpt-5\.[345]|gpt-4\.5|o3)(?:[-.]|$)"),
# Hide dated snapshots and the retired plain gpt-5.3 id.
"model_id_denylist": re.compile(r"^(gpt-5\.3)$|-\d{4}-\d{2}-\d{2}$"),
},
"anthropic": {
"display_name": "Anthropic",
"base_url": "https://api.anthropic.com/v1",
"default_models": [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
],
# Anthropic /v1/models returns dated snapshot ids alongside the
# canonical names (e.g. claude-3-5-sonnet-20241022). Hide the
# YYYYMMDD-suffixed variants from the picker — same intent as the
# OpenAI denylist, just a different date format (no dashes between
# year/month/day).
"model_id_denylist": re.compile(r"-\d{8}$"),
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": False,
"auth_header": "x-api-key",
"auth_prefix": "",
"extra_headers": {
"anthropic-version": "2023-06-01",
},
"openai_compatible": False,
"notes": "Native Anthropic Messages API. Uses x-api-key header and /v1/messages endpoint with SSE translation.",
},
"gemini": {
"display_name": "Google Gemini",
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
# Curated lineup — Google's /v1beta/openai/models returns dozens
# of historical / experimental / embedding ids. Cap to the current
# 3.x family plus the rolling `*-latest` aliases.
"default_models": [
"gemini-3.1-pro-preview",
"gemini-3.1-flash-lite",
"gemini-3-flash-preview",
"gemini-pro-latest",
"gemini-flash-latest",
"gemini-flash-lite-latest",
],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"notes": "OpenAI-compatible endpoint. API key from https://aistudio.google.com/apikey.",
"model_id_allowlist": re.compile(
r"^(gemini-3\.1-flash-lite|gemini-3-flash-preview|"
r"gemini-3\.1-pro-preview|gemini-pro-latest|"
r"gemini-flash-latest|gemini-flash-lite-latest)$"
),
},
"deepseek": {
"display_name": "DeepSeek",
"base_url": "https://api.deepseek.com/v1",
"default_models": [
"deepseek-chat",
"deepseek-reasoner",
],
"supports_streaming": True,
"supports_vision": False,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"notes": "OpenAI-compatible API. deepseek-chat = V3, deepseek-reasoner = R1 thinking mode.",
},
"mistral": {
"display_name": "Mistral AI",
"base_url": "https://api.mistral.ai/v1",
"default_models": [
"codestral-latest",
"devstral-latest",
"devstral-medium-latest",
"magistral-medium-latest",
"ministral-14b-latest",
"ministral-3b-latest",
"ministral-8b-latest",
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"mistral-tiny-latest",
"mistral-vibe-cli-latest",
],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"model_id_allowlist": re.compile(
r"^(codestral-latest|devstral-latest|devstral-medium-latest|"
r"magistral-medium-latest|ministral-(?:14b|3b|8b)-latest|"
r"mistral-(?:large|medium|small|tiny)-latest|"
r"mistral-vibe-cli-latest)$"
),
},
"kimi": {
"display_name": "Kimi",
"base_url": "https://api.moonshot.ai/v1",
# Current Kimi model lineup per the official docs:
# https://platform.kimi.ai/docs/models
# Listing/overview endpoints used to enumerate them:
# https://platform.kimi.ai/docs/api/list-models
# https://platform.kimi.ai/docs/api/overview
# kimi-k2.6 and kimi-k2.5 are the two SoTA multimodal models we
# surface in the picker; everything else (moonshot-v1-*, dated
# k2 previews) is filtered out by model_id_allowlist below.
"default_models": [
"kimi-k2.6",
"kimi-k2.5",
],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"notes": "Moonshot API key. China: use base URL https://api.moonshot.cn/v1",
"model_id_allowlist": re.compile(r"^kimi-k2\.[56]$"),
# Both k2.6 and k2.5 are reasoning-class. The API rejects custom
# sampling: "invalid temperature: only 1 is allowed for this model"
# (and the same shape for top_p). Strip both fields from the
# outbound body so the server falls back to its required defaults.
"body_omit": ("temperature", "top_p"),
},
"qwen": {
"display_name": "Qwen",
"base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
"default_models": [
"qwen-plus",
"qwen-turbo",
"qwen-max",
"qwen2.5-72b-instruct",
],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"notes": "DashScope API key. China mainland: override base URL to https://dashscope.aliyuncs.com/compatible-mode/v1",
},
"huggingface": {
"display_name": "Hugging Face",
"base_url": "https://router.huggingface.co/v1",
# Seed the picker with a few popular ids so something is selectable
# before the live /v1/models call resolves. The remote listing is
# the source of truth — see model_list_mode below.
"default_models": [
"openai/gpt-oss-120b",
"deepseek-ai/DeepSeek-V3",
"meta-llama/Llama-3.3-70B-Instruct",
"Qwen/Qwen2.5-72B-Instruct",
],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"notes": (
"HF token from huggingface.co/settings/tokens. Uses the "
"OpenAI-compatible router at /v1/chat/completions; /v1/models "
"returns the cross-provider chat catalog. See "
"https://huggingface.co/docs/inference-providers/index."
),
# /v1/models works on the HF router and returns the full chat-model
# catalog (state.org/model[:policy] ids). Switch to remote so users
# see live availability — the picker has a search box, and
# loadModels() merges defaults so default_models entries remain
# visible if the remote call fails.
"model_list_mode": "remote",
# Scope the catalog to first-party org repos we trust as primary
# sources. The HF /v1/models response is otherwise hundreds of
# ids long (community fine-tunes, mirrors, fp8 variants, etc.).
"model_id_allowlist": re.compile(
r"^(openai|deepseek-ai|google|meta-llama|Qwen|moonshotai|"
r"mistralai|zai-org)/"
),
# Cap the post-filter list. /v1/models has no server-side limit
# or popularity sort, so this is just "first N matches" — pair it
# with the default_models seed so the most useful flagship ids
# are always among the top regardless of the API's order.
"model_id_limit": 15,
},
"openrouter": {
"display_name": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
# Curated list for Studio's picker (explicitly locked, not live /models).
"default_models": [
"openrouter/free",
"openai/gpt-4o",
"anthropic/claude-sonnet-4-5",
"google/gemini-2.5-flash",
"mistralai/mistral-large-2411",
"deepseek/deepseek-r1",
"mistralai/mistral-small-3.1-24b-instruct",
"perceptron/perceptron-mk1",
"inclusionai/ring-2.6-1t:free",
"google/gemini-3.1-flash-lite",
"baidu/cobuddy:free",
"openai/gpt-chat-latest",
"x-ai/grok-4.3",
"ibm-granite/granite-4.1-8b",
"openrouter/owl-alpha",
"poolside/laguna-xs.2:free",
"~google/gemini-pro-latest",
"~moonshotai/kimi-latest",
],
"supports_streaming": True,
"supports_vision": True,
"supports_tool_calling": True,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"extra_headers": {
"HTTP-Referer": "https://unsloth.ai",
"X-Title": "Unsloth Studio",
},
"notes": "Unified gateway to 300+ models across all major providers. HTTP-Referer and X-Title headers sent for attribution.",
"model_list_mode": "curated",
},
}
def get_provider_info(provider_type: str) -> dict[str, Any] | None:
"""Return the registry entry for a provider type, or None if unknown."""
return PROVIDER_REGISTRY.get(provider_type)
def get_base_url(provider_type: str) -> str | None:
"""Return the default base URL for a provider type."""
info = PROVIDER_REGISTRY.get(provider_type)
return info["base_url"] if info else None
def list_available_providers() -> list[dict[str, Any]]:
"""Return all registered providers (for the /registry endpoint)."""
result = []
for provider_type, info in PROVIDER_REGISTRY.items():
result.append(
{
"provider_type": provider_type,
"display_name": info["display_name"],
"base_url": info["base_url"],
"default_models": info["default_models"],
"supports_streaming": info["supports_streaming"],
"supports_vision": info.get("supports_vision", False),
"supports_tool_calling": info.get("supports_tool_calling", False),
"model_list_mode": info.get("model_list_mode", "remote"),
}
)
return result

View file

@ -10,6 +10,7 @@ Supports web search (DuckDuckGo), Python code execution, and terminal commands.
import ast
import http.client
import os
import signal
os.environ["UNSLOTH_IS_PRESENT"] = "1"
@ -58,21 +59,37 @@ _MAX_OUTPUT_CHARS = 8000 # truncate long output
_BLOCKED_COMMANDS_COMMON = frozenset(
{
"rm",
"sudo",
"su",
"dd",
"chmod",
"chown",
"mkfs",
"shutdown",
"reboot",
"passwd",
"mount",
"umount",
"fdisk",
"sudo",
"su",
"doas",
"pkexec",
"shutdown",
"reboot",
"halt",
"poweroff",
"kill",
"killall",
"pkill",
"passwd",
"curl",
"wget",
"nc",
"ncat",
"netcat",
"socat",
"ssh",
"scp",
"sftp",
"rsync",
"eval",
"source",
}
)
_BLOCKED_COMMANDS_WIN = frozenset(
@ -221,35 +238,67 @@ def _build_safe_env(workdir: str) -> dict[str, str]:
def _sandbox_preexec():
"""Pre-exec hook: drop privilege escalation ability and set resource limits.
"""Best-effort sandbox setup for sandboxed subprocesses.
On Linux, applies PR_SET_NO_NEW_PRIVS so sudo/su/pkexec fail at the
kernel level. On Linux and macOS, sets RLIMIT_FSIZE.
No-op on Windows (use creationflags instead).
Note: RLIMIT_NPROC is intentionally NOT set because Linux enforces it
per real UID, not per process tree, so it would starve the Studio
server and other sessions sharing the same user account.
All modules and handles are resolved at import time (module level) so
this function does not trigger Python imports in the forked child,
avoiding potential deadlocks in multi-threaded servers.
Modules are resolved at import time so the forked child runs no imports.
"""
try:
os.setsid()
except OSError:
pass
try:
os.umask(0o077)
except OSError:
pass
if _libc is not None:
try:
# PR_SET_NO_NEW_PRIVS = 38, arg2 = 1 (enable)
_libc.prctl(38, 1, 0, 0, 0)
_libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS
except (OSError, AttributeError):
pass # Not available (container, old kernel, etc.)
pass
try:
_libc.prctl(1, 9, 0, 0, 0) # PR_SET_PDEATHSIG = SIGKILL
except (OSError, AttributeError):
pass
# CLONE_NEWNET intentionally not applied: where userns is enabled it
# blocks all egress, including allowlisted hosts. Network policy is
# enforced by the AST host check and the bash blocklist.
if _resource is not None:
# RLIMIT_NPROC is per-real-UID, so the cap is well above normal usage.
try:
nproc = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NPROC", "10000"))
_resource.setrlimit(_resource.RLIMIT_NPROC, (nproc, nproc))
except (ValueError, OSError, AttributeError):
pass
try:
# Limit file size to 100MB (prevents disk filling)
_resource.setrlimit(
_resource.RLIMIT_FSIZE, (100 * 1024 * 1024, 100 * 1024 * 1024)
)
except (ValueError, OSError):
pass
try:
as_bytes = (
int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_AS_GB", "8"))
* 1024
* 1024
* 1024
)
_resource.setrlimit(_resource.RLIMIT_AS, (as_bytes, as_bytes))
except (ValueError, OSError, AttributeError):
pass
try:
cpu_s = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_CPU_S", "600"))
_resource.setrlimit(_resource.RLIMIT_CPU, (cpu_s, cpu_s))
except (ValueError, OSError, AttributeError):
pass
try:
_resource.setrlimit(_resource.RLIMIT_NOFILE, (1024, 1024))
except (ValueError, OSError, AttributeError):
pass
def _get_shell_cmd(command: str) -> list[str]:
@ -265,25 +314,36 @@ def _get_shell_cmd(command: str) -> list[str]:
_workdirs: dict[str, str] = {}
# Non-matching session_ids collapse to ``_invalid`` to block cross-session escapes.
_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z")
def _get_workdir(session_id: str | None = None) -> str:
"""Return (and lazily create) a persistent working directory for tool execution."""
"""Return a per-session sandbox dir at mode 0o700."""
global _workdirs
key = session_id or "_default"
if key not in _workdirs or not os.path.isdir(_workdirs[key]):
home = os.path.expanduser("~")
sandbox_root = os.path.join(home, "studio_sandbox")
if session_id:
# Sanitize: strip path separators and parent-dir references
safe_id = os.path.basename(session_id.replace("..", ""))
if not safe_id:
safe_id = "_invalid"
workdir = os.path.join(sandbox_root, safe_id)
# Verify resolved path stays under sandbox root
if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)):
if session_id and _SESSION_ID_RE.match(session_id):
workdir = os.path.join(sandbox_root, session_id)
if not os.path.realpath(workdir).startswith(
os.path.realpath(sandbox_root) + os.sep
):
workdir = os.path.join(sandbox_root, "_invalid")
elif session_id:
workdir = os.path.join(sandbox_root, "_invalid")
else:
workdir = os.path.join(sandbox_root, "_default")
os.makedirs(workdir, exist_ok = True)
try:
os.chmod(sandbox_root, 0o700)
except OSError:
pass
try:
os.chmod(workdir, 0o700)
except OSError:
pass
_workdirs[key] = workdir
return _workdirs[key]
@ -932,7 +992,12 @@ def _check_signal_escape_patterns(code: str):
isinstance(shell_node, ast.Constant)
and shell_node.value is False
)
if shell_func in _STRING_SHELL_FUNCS or not shell_safe:
# Dynamic shell-exec args (chr/format/concat bypasses).
if (
shell_func in _STRING_SHELL_FUNCS
or shell_func in _SHELL_EXEC_FUNCS
or not shell_safe
):
def _is_safe_literal(n):
if _extract_string_from_node(n) is not None:
@ -1006,15 +1071,418 @@ def _check_signal_escape_patterns(code: str):
if visitor.imports_signal and not signal_tampering:
warnings.append("Code imports 'signal' module - review manually for safety")
# Static host policy: block metadata hosts and any literal host outside
# the trusted allowlist; uploads blocked regardless of host. Dynamic hosts
# are caught by the bash blocklist instead.
network_calls: list[dict] = []
sensitive_file_reads: list[dict] = []
_NETWORK_FQ_PREFIXES = (
"socket.socket",
"socket.create_connection",
"socket.getaddrinfo",
"urllib.request.urlopen",
"urllib.request.urlretrieve",
"urllib3.",
"requests.get",
"requests.post",
"requests.put",
"requests.delete",
"requests.patch",
"requests.head",
"requests.request",
"requests.Session",
"http.client.HTTPConnection",
"http.client.HTTPSConnection",
"httpx.get",
"httpx.post",
"httpx.put",
"httpx.patch",
"httpx.delete",
"httpx.request",
"httpx.Client",
"httpx.AsyncClient",
"aiohttp.ClientSession",
)
_UPLOAD_HTTP_METHODS = (
"requests.post",
"requests.put",
"requests.patch",
"requests.delete",
"requests.request",
"httpx.post",
"httpx.put",
"httpx.patch",
"httpx.delete",
"httpx.request",
"urllib.request.urlopen",
"urllib.request.Request",
)
_UPLOAD_HF_FQ = (
"huggingface_hub.upload_file",
"huggingface_hub.upload_folder",
"huggingface_hub.upload_large_folder",
"huggingface_hub.create_commit",
)
_UPLOAD_HF_METHODS = frozenset(
{
"upload_file",
"upload_folder",
"upload_large_folder",
"create_commit",
}
)
# Cloud-metadata / link-local hosts.
_METADATA_HOST_LITERALS = {
"169.254.169.254",
"fd00:ec2::254",
"metadata.google.internal",
"metadata",
"metadata.tencentyun.com",
"100.100.100.200",
"100.100.100.110",
"169.254.170.2",
"169.254.170.23",
}
_METADATA_HOST_PREFIXES = (
"169.254.",
"100.64.",
)
# Allowlist kept explicit so each entry is auditable.
_TRUSTED_PUBLIC_HOST_LITERALS = frozenset(
{
# search
"www.google.com",
"google.com",
"www.bing.com",
"bing.com",
"duckduckgo.com",
"html.duckduckgo.com",
# encyclopedic / reference
"wikipedia.org",
"www.wikipedia.org",
"wikimedia.org",
"www.wikimedia.org",
"wikidata.org",
"www.wikidata.org",
"commons.wikimedia.org",
"www.britannica.com",
"openlibrary.org",
"www.openstreetmap.org",
# ML / dev / data
"huggingface.co",
"hf.co",
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"gist.github.com",
"docs.github.com",
"pypi.org",
"files.pythonhosted.org",
"www.npmjs.com",
"registry.npmjs.org",
"crates.io",
"static.crates.io",
# docs
"docs.python.org",
"python.org",
"www.python.org",
"developer.mozilla.org",
"developer.apple.com",
"learn.microsoft.com",
"docs.docker.com",
"pytorch.org",
"docs.pytorch.org",
"tensorflow.org",
"www.tensorflow.org",
"numpy.org",
"pandas.pydata.org",
"scipy.org",
"scikit-learn.org",
"matplotlib.org",
"fastapi.tiangolo.com",
"starlette.io",
# academic
"arxiv.org",
"export.arxiv.org",
"scholar.google.com",
"openreview.net",
"semanticscholar.org",
"www.semanticscholar.org",
"biorxiv.org",
"www.biorxiv.org",
"medrxiv.org",
"www.medrxiv.org",
"pubmed.ncbi.nlm.nih.gov",
"www.ncbi.nlm.nih.gov",
# Q&A / community
"stackoverflow.com",
"stackexchange.com",
"askubuntu.com",
"superuser.com",
"serverfault.com",
# standards
"www.w3.org",
"tools.ietf.org",
"datatracker.ietf.org",
"www.rfc-editor.org",
# reputable news
"www.bbc.com",
"www.bbc.co.uk",
"www.reuters.com",
"apnews.com",
"www.nature.com",
"www.science.org",
# government / open data
"data.gov",
"catalog.data.gov",
"www.census.gov",
"www.nasa.gov",
"data.nasa.gov",
"www.cdc.gov",
"www.nih.gov",
"www.who.int",
# weather / time
"api.weather.gov",
"worldtimeapi.org",
}
)
_TRUSTED_PUBLIC_HOST_SUFFIXES = (
".wikipedia.org",
".wikimedia.org",
".wiktionary.org",
".wikibooks.org",
".wikiquote.org",
".wikisource.org",
".wikiversity.org",
".wikivoyage.org",
".stackexchange.com",
".hf.co",
".huggingface.co",
".githubusercontent.com",
".github.io",
".arxiv.org",
".readthedocs.io",
".readthedocs.org",
)
_SENSITIVE_FILE_PREFIXES = (
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/etc/ssh/",
)
_SENSITIVE_FILE_RE = re.compile(
r"^/proc/(?:self|\d+)/(?:environ|cmdline|task/\d+/environ)$"
)
def _normalize_host(host: str) -> str:
if not host:
return ""
h = host.strip().lower().rstrip(".")
if "@" in h:
h = h.split("@", 1)[1]
if h.startswith("[") and "]" in h:
h = h[1 : h.index("]")]
elif h.count(":") == 1:
h = h.split(":", 1)[0]
return h
def _is_metadata_host(host: str) -> bool:
h = _normalize_host(host)
if not h:
return False
if h in _METADATA_HOST_LITERALS:
return True
if any(h.startswith(p) for p in _METADATA_HOST_PREFIXES):
return True
return False
def _is_trusted_host(host: str) -> bool:
h = _normalize_host(host)
if not h:
return False
if h in _TRUSTED_PUBLIC_HOST_LITERALS:
return True
return any(h.endswith(s) for s in _TRUSTED_PUBLIC_HOST_SUFFIXES)
def _call_is_upload_shape(node: ast.Call, fq: str) -> bool:
"""True for statically obvious upload shapes (files=, data=open(), bytes literal)."""
if fq in _UPLOAD_HF_FQ:
return True
if fq not in _UPLOAD_HTTP_METHODS:
return False
for kw in node.keywords or []:
if kw.arg == "files":
return True
if kw.arg == "data":
v = kw.value
if (
isinstance(v, ast.Call)
and isinstance(v.func, ast.Name)
and v.func.id == "open"
):
return True
if isinstance(v, ast.Constant) and isinstance(
v.value, (bytes, bytearray)
):
return True
return False
def _method_call_is_hf_upload(node: ast.Call) -> bool:
"""True for HfApi upload method names on any receiver."""
return (
isinstance(node.func, ast.Attribute)
and node.func.attr in _UPLOAD_HF_METHODS
)
class NetworkAndIoVisitor(ast.NodeVisitor):
def visit_Call(self, node):
parts: list[str] = []
cur = node.func
while isinstance(cur, ast.Attribute):
parts.insert(0, cur.attr)
cur = cur.value
if isinstance(cur, ast.Name):
parts.insert(0, cur.id)
fq = ".".join(parts) if parts else ""
if _method_call_is_hf_upload(node):
network_calls.append(
{
"type": "upload_blocked",
"line": getattr(node, "lineno", -1),
"description": ("Blocked: file upload disallowed in sandbox"),
}
)
# Direct sock.connect((host, port)) bypasses the FQ-prefix branch below.
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "connect"
and node.args
):
a0 = node.args[0]
host_lit = None
if isinstance(a0, ast.Tuple) and a0.elts:
e0 = a0.elts[0]
if isinstance(e0, ast.Constant) and isinstance(e0.value, str):
host_lit = e0.value
elif isinstance(a0, ast.Constant) and isinstance(a0.value, str):
host_lit = a0.value
if host_lit:
if _is_metadata_host(host_lit):
network_calls.append(
{
"type": "metadata_host_blocked",
"line": getattr(node, "lineno", -1),
"description": "Blocked: cloud-metadata host",
}
)
elif not _is_trusted_host(host_lit):
network_calls.append(
{
"type": "untrusted_host_blocked",
"line": getattr(node, "lineno", -1),
"description": (
"Blocked: host not in sandbox allowlist; "
"use an allowed informational source"
),
}
)
if fq and any(fq.startswith(p) for p in _NETWORK_FQ_PREFIXES):
# 1) Upload-shape check (host-independent).
if _call_is_upload_shape(node, fq):
network_calls.append(
{
"type": "upload_blocked",
"line": getattr(node, "lineno", -1),
"description": (
"Blocked: file upload disallowed in sandbox"
),
}
)
# 2) Extract literal host (URL string or (host, port) tuple).
host_arg = None
url_arg = None
if node.args:
a0 = node.args[0]
if isinstance(a0, ast.Constant) and isinstance(a0.value, str):
url_arg = a0.value
elif isinstance(a0, ast.Tuple) and a0.elts:
e0 = a0.elts[0]
if isinstance(e0, ast.Constant) and isinstance(e0.value, str):
host_arg = e0.value
if url_arg and host_arg is None:
m = re.match(r"^\w+://([^/?#]+)", url_arg)
if m:
host_arg = m.group(1)
if host_arg:
if _is_metadata_host(host_arg):
network_calls.append(
{
"type": "metadata_host_blocked",
"line": getattr(node, "lineno", -1),
"description": "Blocked: cloud-metadata host",
}
)
elif not _is_trusted_host(host_arg):
network_calls.append(
{
"type": "untrusted_host_blocked",
"line": getattr(node, "lineno", -1),
"description": (
"Blocked: host not in sandbox allowlist; "
"use an allowed informational source"
),
}
)
is_open_call = (
(isinstance(node.func, ast.Name) and node.func.id == "open")
or fq in ("io.open", "pathlib.Path.open")
or fq.endswith(".open")
)
if is_open_call and node.args:
a0 = node.args[0]
path_lit = None
if isinstance(a0, ast.Constant) and isinstance(a0.value, str):
path_lit = a0.value
if path_lit:
flagged = False
if any(path_lit.startswith(p) for p in _SENSITIVE_FILE_PREFIXES):
flagged = True
elif _SENSITIVE_FILE_RE.match(path_lit):
flagged = True
if flagged:
sensitive_file_reads.append(
{
"type": "sensitive_file_read",
"line": getattr(node, "lineno", -1),
"description": (
f"open({path_lit!r}) targets a host identity / "
"credential file; sandboxed code may not read it"
),
}
)
self.generic_visit(node)
NetworkAndIoVisitor().visit(tree)
is_safe = (
len(signal_tampering) == 0
and len(exception_catching) == 0
and len(shell_escapes) == 0
and len(network_calls) == 0
and len(sensitive_file_reads) == 0
)
return is_safe, {
"signal_tampering": signal_tampering,
"exception_catching": exception_catching,
"shell_escapes": shell_escapes,
"network_calls": network_calls,
"sensitive_file_reads": sensitive_file_reads,
"warnings": warnings,
}
@ -1041,7 +1509,21 @@ def _check_code_safety(code: str) -> str | None:
exception_reasons = [
item.get("description", "") for item in info.get("exception_catching", [])
]
all_reasons = [r for r in reasons + shell_reasons + exception_reasons if r]
network_reasons = [
item.get("description", "") for item in info.get("network_calls", [])
]
file_reasons = [
item.get("description", "") for item in info.get("sensitive_file_reads", [])
]
all_reasons = [
r
for r in reasons
+ shell_reasons
+ exception_reasons
+ network_reasons
+ file_reasons
if r
]
if all_reasons:
return (
f"Error: unsafe code detected ({'; '.join(all_reasons)}). "
@ -1051,11 +1533,31 @@ def _check_code_safety(code: str) -> str | None:
return None
def _kill_process_tree(proc) -> None:
"""SIGKILL the setsid process group; fall back to single-pid kill."""
if proc.poll() is not None:
return
try:
pgid = os.getpgid(proc.pid)
except (ProcessLookupError, PermissionError):
pgid = None
if pgid is not None:
try:
os.killpg(pgid, signal.SIGKILL)
return
except (ProcessLookupError, PermissionError):
pass
try:
proc.kill()
except (ProcessLookupError, PermissionError):
pass
def _cancel_watcher(proc, cancel_event, poll_interval = 0.2):
"""Daemon thread that kills a process when cancel_event is set."""
while proc.poll() is None:
if cancel_event is not None and cancel_event.is_set():
proc.kill()
_kill_process_tree(proc)
return
cancel_event.wait(poll_interval) if cancel_event else None
@ -1126,8 +1628,11 @@ def _python_exec(
try:
output, _ = proc.communicate(timeout = timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
_kill_process_tree(proc)
try:
proc.communicate(timeout = 5)
except subprocess.TimeoutExpired:
pass
return _truncate(f"Execution timed out after {timeout} seconds.")
if cancel_event is not None and cancel_event.is_set():
@ -1211,8 +1716,11 @@ def _bash_exec(
try:
output, _ = proc.communicate(timeout = timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
_kill_process_tree(proc)
try:
proc.communicate(timeout = 5)
except subprocess.TimeoutExpired:
pass
return _truncate(f"Execution timed out after {timeout} seconds.")
if cancel_event is not None and cancel_event.is_set():

View file

@ -3208,6 +3208,9 @@ class UnslothTrainer:
if eval_steps_val > 0:
config_args["eval_strategy"] = "steps"
config_args["eval_steps"] = eval_steps_val
config_args["per_device_eval_batch_size"] = config_args[
"per_device_train_batch_size"
]
logger.info(
f"✅ Evaluation enabled: eval_steps={eval_steps_val} (fraction of total steps)\n"
)

View file

@ -17,7 +17,9 @@ Pattern follows core/data_recipe/jobs/manager.py.
import json as _json
import math
import multiprocessing as mp
import os
import queue
import shutil
import threading
import time
import structlog
@ -33,9 +35,54 @@ from utils.native_path_leases import (
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
)
from utils.paths import outputs_root
logger = get_logger(__name__)
def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
"""Remove ``checkpoint-<int>`` subdirs after a cancelled run.
Only paths whose realpath is under outputs_root are touched."""
out = Path(output_dir)
if not out.exists():
return
try:
out_real = out.resolve()
out_root_real = Path(outputs_root()).resolve()
except OSError:
return
try:
out_real.relative_to(out_root_real)
except ValueError:
# Refuse to delete anything outside the configured outputs root.
logger.warning(
"Skipping checkpoint cleanup - %s is not under outputs_root %s",
out_real,
out_root_real,
)
return
removed = 0
for entry in out.iterdir() if out.is_dir() else []:
if not entry.is_dir():
continue
name = entry.name
if not name.startswith("checkpoint-"):
continue
tail = name[len("checkpoint-") :]
if not tail.isdigit():
continue
try:
shutil.rmtree(entry, ignore_errors = False)
removed += 1
except OSError as exc:
logger.warning("Could not remove %s: %s", entry, exc)
logger.info(
"Cancelled-run cleanup removed %d checkpoint dir(s) under %s",
removed,
out,
)
_CTX = mp.get_context("spawn")
# Plot styling constants
@ -167,6 +214,7 @@ class TrainingBackend:
"max_steps": kwargs.get("max_steps", 0),
"save_steps": kwargs.get("save_steps", 0),
"weight_decay": kwargs.get("weight_decay", 0.001),
"max_grad_norm": kwargs.get("max_grad_norm", 0.0),
"random_seed": kwargs.get("random_seed", 3407),
"packing": kwargs.get("packing", False),
"optim": kwargs.get("optim", "adamw_8bit"),
@ -316,6 +364,8 @@ class TrainingBackend:
)
self._proc.terminate()
proc = self._proc
cancelled = self._cancel_requested
output_dir = self._output_dir
if proc is not None:
proc.join(timeout = 5.0)
@ -328,6 +378,17 @@ class TrainingBackend:
if self._pump_thread is not None and self._pump_thread.is_alive():
self._pump_thread.join(timeout = 8.0)
# Drop checkpoint-* dirs on explicit cancel only; stop-and-save
# keeps its artifacts.
if cancelled and output_dir:
try:
_cleanup_cancelled_checkpoints(output_dir)
except Exception:
logger.exception(
"Failed to clean up cancelled-run checkpoints under %s",
output_dir,
)
def is_training_active(self) -> bool:
"""Check if training is currently active."""
with self._lock:

View file

@ -30,6 +30,7 @@ from utils.hardware import apply_gpu_ids
from utils.wheel_utils import (
direct_wheel_url,
flash_attn_wheel_url,
has_blackwell_gpu,
install_wheel,
probe_torch_wheel_env,
url_exists,
@ -313,6 +314,12 @@ def _should_try_runtime_flash_attn_install(max_seq_length: int) -> bool:
def _ensure_flash_attn_for_long_context(event_queue: Any, max_seq_length: int) -> None:
if not _should_try_runtime_flash_attn_install(max_seq_length):
return
if has_blackwell_gpu():
_send_status(
event_queue,
"Skipping flash-attn install: Blackwell GPU detected (sm_100+); no compatible prebuilt wheel",
)
return
installed = _install_package_wheel_first(
event_queue = event_queue,
@ -417,6 +424,55 @@ def _normalize_mlx_studio_scheduler(value):
return raw
def _resolve_mlx_local_dataset_files(file_paths: list) -> list[str]:
"""Resolve Studio local dataset uploads without importing the GPU trainer."""
from utils.paths import resolve_dataset_path
all_files: list[str] = []
for dataset_file in file_paths or []:
file_path = (
dataset_file
if os.path.isabs(dataset_file)
else str(resolve_dataset_path(dataset_file))
)
file_path_obj = Path(file_path)
if file_path_obj.is_dir():
parquet_dir = (
file_path_obj / "parquet-files"
if (file_path_obj / "parquet-files").exists()
else file_path_obj
)
parquet_files = sorted(parquet_dir.glob("*.parquet"))
if parquet_files:
all_files.extend(str(p) for p in parquet_files)
continue
candidates: list[Path] = []
for ext in (".json", ".jsonl", ".csv", ".parquet"):
candidates.extend(sorted(file_path_obj.glob(f"*{ext}")))
if candidates:
all_files.extend(str(c) for c in candidates)
continue
raise ValueError(f"No supported data files in directory: {file_path_obj}")
all_files.append(str(file_path_obj))
return all_files
def _mlx_local_dataset_loader_for_files(files: list[str]) -> str:
first_ext = Path(files[0]).suffix.lower()
if first_ext in (".json", ".jsonl"):
return "json"
if first_ext == ".csv":
return "csv"
if first_ext == ".parquet":
return "parquet"
raise ValueError(f"Unsupported dataset format: {files[0]}")
def _run_mlx_training(event_queue, stop_queue, config):
"""Self-contained MLX training path for Apple Silicon.
@ -442,8 +498,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
import mlx.core as mx
try:
from unsloth_zoo.mlx_loader import FastMLXModel
from unsloth_zoo.mlx_trainer import (
from unsloth_zoo.mlx.loader import FastMLXModel
from unsloth_zoo.mlx.trainer import (
MLXTrainer,
MLXTrainingConfig,
train_on_responses_only,
@ -451,7 +507,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
except ImportError as e:
raise ImportError(
"Unsloth: MLX training requires unsloth-zoo with the MLX modules "
"(unsloth_zoo.mlx_loader / unsloth_zoo.mlx_trainer). Reinstall via "
"(unsloth_zoo.mlx.loader / unsloth_zoo.mlx.trainer). Reinstall via "
"install.sh on Apple Silicon."
) from e
from datasets import load_dataset
@ -572,7 +628,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
return ds
def _load_local(file_paths):
from core.training.trainer import UnslothTrainer
from datasets import load_from_disk
if len(file_paths) == 1:
@ -581,10 +636,10 @@ def _run_mlx_training(event_queue, stop_queue, config):
(p / "dataset_info.json").exists() or (p / "state.json").exists()
):
return load_from_disk(str(p))
all_files = UnslothTrainer._resolve_local_files(file_paths)
all_files = _resolve_mlx_local_dataset_files(file_paths)
if not all_files:
raise ValueError("No local dataset files found")
loader = UnslothTrainer._loader_for_files(all_files)
loader = _mlx_local_dataset_loader_for_files(all_files)
return load_dataset(loader, data_files = all_files, split = "train")
if hf_dataset:
@ -718,6 +773,10 @@ def _run_mlx_training(event_queue, stop_queue, config):
else:
eval_steps_val = int(eval_steps_val)
# MLX: value-clip grads to [-5, 5]; norm clipping disabled for compile-friendliness.
max_grad_norm = 0.0
max_grad_value = 5.0 # TODO: expose MLX grad-clip in Studio UI for power users
trainer = MLXTrainer(
model = model,
tokenizer = tokenizer,
@ -732,6 +791,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
lr_scheduler_type = lr_scheduler_type,
optim = optim_name,
weight_decay = float(config.get("weight_decay", 0.001) or 0.001),
max_grad_norm = max_grad_norm,
max_grad_value = max_grad_value,
logging_steps = 1,
max_seq_length = max_seq_length,
seed = config.get("random_seed", 3407),
@ -820,7 +881,17 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 9. Real-time progress callback ──
_send("status", status_message = f"Training {model_name}...")
def _on_step(step, total, loss, lr, tok_s, peak_gb, elapsed, num_tokens):
def _on_step(
step,
total,
loss,
lr,
tok_s,
peak_gb,
elapsed,
num_tokens,
grad_norm = None,
):
eta = (elapsed / step * (total - step)) if step > 0 else 0
_send(
"progress",
@ -831,7 +902,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
total_steps = total,
elapsed_seconds = elapsed,
eta_seconds = max(0, eta),
grad_norm = None,
grad_norm = grad_norm,
num_tokens = num_tokens,
eval_loss = None,
status_message = None,
@ -846,6 +917,11 @@ def _run_mlx_training(event_queue, stop_queue, config):
"train/tokens_per_sec": tok_s,
"train/peak_gb": peak_gb,
"train/num_tokens": num_tokens,
**(
{"train/grad_norm": grad_norm}
if grad_norm is not None
else {}
),
},
step = step,
)
@ -857,6 +933,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
tb_writer.add_scalar("train/learning_rate", lr, step)
tb_writer.add_scalar("train/tokens_per_sec", tok_s, step)
tb_writer.add_scalar("train/peak_gb", peak_gb, step)
if grad_norm is not None:
tb_writer.add_scalar("train/grad_norm", grad_norm, step)
except Exception:
pass

View file

@ -77,6 +77,7 @@ if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_STUDIO_ROOT_RESOLVED / "llama.cpp")
import hashlib
import mimetypes
import re as _re
import shutil
@ -138,7 +139,7 @@ if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
# warnings.filterwarnings("ignore", category=DeprecationWarning)
# warnings.filterwarnings("ignore", module="triton.*")
from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, Response
@ -154,6 +155,7 @@ from routes import (
inference_router,
inference_studio_router,
models_router,
providers_router,
training_history_router,
training_router,
)
@ -169,6 +171,11 @@ import utils.hardware.hardware as _hw_module
from utils.cache_cleanup import clear_unsloth_compiled_cache
from utils.native_path_leases import native_path_leases_supported
from utils.update_status import (
get_studio_install_source_status,
get_studio_update_status,
)
from utils.studio_version import get_studio_version
def get_unsloth_version() -> str:
@ -190,6 +197,25 @@ def get_unsloth_version() -> str:
UNSLOTH_VERSION = get_unsloth_version()
STUDIO_VERSION = get_studio_version()
def _load_desktop_owner() -> dict[str, str] | None:
token = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_TOKEN", "")
kind = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_KIND", "")
if kind != "tauri" or not token:
return None
return {
"kind": "tauri",
"token_sha256": hashlib.sha256(token.encode("utf-8")).hexdigest(),
}
_DESKTOP_OWNER = _load_desktop_owner()
def _desktop_owner() -> dict[str, str] | None:
return _DESKTOP_OWNER
@asynccontextmanager
@ -232,6 +258,11 @@ async def lifespan(app: FastAPI):
threading.Thread(target = _precache, daemon = True).start()
# Initialize RSA key pair for API key encryption (external providers)
from core.inference.key_exchange import init_key_pair
init_key_pair()
if storage.ensure_default_admin():
bootstrap_pw = storage.get_bootstrap_password()
app.state.bootstrap_password = bootstrap_pw
@ -270,6 +301,181 @@ logger = LogConfig.setup_logging(
app.add_middleware(LoggingMiddleware)
# Web-search favicons load from *.gstatic.com; everything else is same-origin.
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.requests import Request as _StarletteRequest # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
def _build_csp(script_nonce: "str | None" = None) -> str:
script_src = "script-src 'self'"
if script_nonce:
script_src += f" 'nonce-{script_nonce}'"
return (
"default-src 'self'; "
"img-src 'self' data: blob: https://t0.gstatic.com "
"https://t1.gstatic.com https://t2.gstatic.com "
"https://t3.gstatic.com; "
"connect-src 'self' https://huggingface.co https://datasets-server.huggingface.co; "
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Set baseline security headers; splice per-response inline-script nonces into CSP."""
async def dispatch(self, request: _StarletteRequest, call_next):
response = await call_next(request)
# Strip the internal nonce hand-off header so it never reaches the client.
nonce = response.headers.get(_CSP_SCRIPT_NONCE_HEADER)
if nonce is not None:
del response.headers[_CSP_SCRIPT_NONCE_HEADER]
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
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=()",
)
response.headers["server"] = "unsloth-studio"
return response
app.add_middleware(SecurityHeadersMiddleware)
# Cap upload body on protected POSTs; default 500 MB, env-tunable.
import json as _json_for_413 # noqa: E402
_MAX_BODY_BYTES = int(os.environ.get("UNSLOTH_STUDIO_MAX_BODY_MB", "500")) * 1024 * 1024
_BODY_PROTECTED_PREFIXES = (
"/v1/chat/completions",
"/v1/completions",
"/api/inference",
"/api/data-recipe",
"/api/datasets",
"/api/train",
"/api/export",
)
async def _send_413(send, total_bytes: int) -> None:
payload = _json_for_413.dumps(
{
"detail": (
f"Request body too large "
f"({total_bytes:,} bytes; max {_MAX_BODY_BYTES:,})."
)
},
).encode("utf-8")
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": payload, "more_body": False})
class MaxBodyMiddleware:
"""Reject oversized bodies on protected POST/PUT/PATCH; raw ASGI so chunked uploads cannot bypass the cap."""
def __init__(self, app, max_bytes: int, protected_prefixes: tuple):
self.app = app
self.max_bytes = max_bytes
self.protected_prefixes = protected_prefixes
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = scope.get("method", "").upper()
path = scope.get("path", "")
if method not in ("POST", "PUT", "PATCH") or not any(
path.startswith(p) for p in self.protected_prefixes
):
await self.app(scope, receive, send)
return
declared = None
for name, value in scope.get("headers", []):
if name == b"content-length":
try:
declared = int(value.decode("latin-1"))
except (ValueError, UnicodeDecodeError):
declared = None
break
if declared is not None and declared > self.max_bytes:
await _send_413(send, declared)
return
chunks: list = []
total = 0
while True:
msg = await receive()
mtype = msg.get("type")
if mtype == "http.disconnect":
return
if mtype != "http.request":
# Mid-stream unexpected frame: forwarding would corrupt downstream.
return
body = msg.get("body", b"") or b""
if body:
total += len(body)
if total > self.max_bytes:
await _send_413(send, total)
return
chunks.append(body)
if not msg.get("more_body", False):
break
replayed = {"sent": False}
async def replay_receive():
if not replayed["sent"]:
replayed["sent"] = True
return {
"type": "http.request",
"body": b"".join(chunks),
"more_body": False,
}
# After replay, fall through so http.disconnect still propagates.
return await receive()
await self.app(scope, replay_receive, send)
app.add_middleware(
MaxBodyMiddleware,
max_bytes = _MAX_BODY_BYTES,
protected_prefixes = _BODY_PROTECTED_PREFIXES,
)
from starlette.responses import RedirectResponse as _RedirectResponse # noqa: E402
@app.get("/recipes", include_in_schema = False)
@app.get("/recipes/{rest:path}", include_in_schema = False)
async def _recipes_redirect(rest: str = ""):
target = "/data-recipes" + (("/" + rest) if rest else "")
return _RedirectResponse(url = target, status_code = 308)
# CORS middleware
_api_only = os.environ.get("UNSLOTH_API_ONLY") == "1"
_cors_origins = ["*"]
@ -309,6 +515,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["
# so external tools (Open WebUI, SillyTavern, etc.) can use the
# standard /v1/chat/completions path.
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
app.include_router(export_router, prefix = "/api/export", tags = ["export"])
@ -321,28 +528,63 @@ app.include_router(
@app.get("/api/health")
async def health_check():
"""Health check endpoint"""
platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"}
device_type = platform_map.get(sys.platform, sys.platform)
return {
async def health_check(request: Request):
"""Liveness only; full diagnostic dict gated on a valid bearer."""
minimal = {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
}
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return minimal
try:
from auth.authentication import get_current_subject as _gcs
from fastapi.security import HTTPAuthorizationCredentials
creds = HTTPAuthorizationCredentials(
scheme = "Bearer", credentials = auth.split(" ", 1)[1]
)
# Must await: a bare coroutine is truthy and would skip the auth check.
subject = await _gcs(creds)
except HTTPException:
return minimal
except Exception:
return minimal
if not subject:
return minimal
platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"}
device_type = platform_map.get(sys.platform, sys.platform)
return {
**minimal,
"service": "Unsloth UI Backend",
"version": UNSLOTH_VERSION,
"studio_version": STUDIO_VERSION,
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
"supports_desktop_auth": True,
# why: launchers compare against an install-time hash so a sibling
# Studio on the same port is rejected; hex digest avoids leaking the
# raw install path on -H 0.0.0.0.
"supports_desktop_backend_ownership": True,
# Hex digest of the install path; launchers reject sibling Studios on the same port.
"studio_root_id": _studio_root_id(),
"native_path_leases_supported": native_path_leases_supported(),
**({"desktop_owner": owner} if (owner := _desktop_owner()) else {}),
}
@app.get("/api/studio/install-source")
def studio_install_source(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware install metadata without remote update checks."""
return get_studio_install_source_status(UNSLOTH_VERSION)
@app.get("/api/studio/update-status")
def studio_update_status(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware manual update status for browser-served Studio."""
return get_studio_update_status(UNSLOTH_VERSION)
@app.post("/api/shutdown")
async def shutdown_server(
request: Request,
@ -372,8 +614,17 @@ async def shutdown_server(
@app.get("/api/system")
async def get_system_info():
"""Get system information"""
async def get_system_info(
current_subject: str = Depends(get_current_subject),
):
"""Get system information.
Gated behind auth: the response includes platform, Python version,
GPU name, memory total, and ML package set -- enough to fingerprint
a host. Studio's chat-only-mode design assumes only the local user
reaches /api/system; in -H 0.0.0.0 / Colab / Tauri-relayed setups
that assumption breaks unless we require a bearer.
"""
import platform
import psutil
from utils.hardware import get_device
@ -413,8 +664,14 @@ async def get_gpu_visibility(
@app.get("/api/system/hardware")
async def get_hardware_info():
"""Return GPU name, total VRAM, and key ML package versions."""
async def get_hardware_info(
current_subject: str = Depends(get_current_subject),
):
"""Return GPU name, total VRAM, and key ML package versions.
Gated behind auth alongside /api/system -- same fingerprinting
concern. /api/system/gpu-visibility is also auth-gated already.
"""
from utils.hardware import get_gpu_summary, get_package_versions
return {
@ -442,21 +699,22 @@ def _strip_crossorigin(html_bytes: bytes) -> bytes:
return html.encode("utf-8")
def _inject_bootstrap(html_bytes: bytes, app: FastAPI) -> bytes:
"""Inject bootstrap credentials into HTML when password change is required.
def _inject_bootstrap(html_bytes: bytes, app: FastAPI):
"""Inject bootstrap credentials when password change is pending.
The script tag is only injected while the default admin account still
has ``must_change_password=True``. Once the user changes the password
the HTML is served clean no credentials leak.
Returns ``(html_bytes, script_nonce_or_None)``. Callers must forward
the nonce via ``_CSP_SCRIPT_NONCE_HEADER`` so the inline script is
not blocked by CSP.
"""
import json as _json
import secrets as _secrets
if not storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME):
return html_bytes
return html_bytes, None
bootstrap_pw = getattr(app.state, "bootstrap_password", None)
if not bootstrap_pw:
return html_bytes
return html_bytes, None
payload = _json.dumps(
{
@ -464,10 +722,11 @@ def _inject_bootstrap(html_bytes: bytes, app: FastAPI) -> bytes:
"password": bootstrap_pw,
}
)
tag = f"<script>window.__UNSLOTH_BOOTSTRAP__={payload}</script>"
nonce = _secrets.token_urlsafe(16)
tag = f'<script nonce="{nonce}">window.__UNSLOTH_BOOTSTRAP__={payload}</script>'
html = html_bytes.decode("utf-8")
html = html.replace("</head>", f"{tag}</head>", 1)
return html.encode("utf-8")
return html.encode("utf-8"), nonce
def setup_frontend(app: FastAPI, build_path: Path):
@ -480,17 +739,23 @@ def setup_frontend(app: FastAPI, build_path: Path):
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets")
@app.get("/")
async def serve_root():
def _build_index_response() -> Response:
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
content = _inject_bootstrap(content, app)
content, nonce = _inject_bootstrap(content, app)
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}
if nonce:
headers[_CSP_SCRIPT_NONCE_HEADER] = nonce
return Response(
content = content,
media_type = "text/html",
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
headers = headers,
)
@app.get("/")
async def serve_root():
return _build_index_response()
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
if full_path in {"api", "v1"} or full_path.startswith(("api/", "v1/")):
@ -506,13 +771,6 @@ def setup_frontend(app: FastAPI, build_path: Path):
return FileResponse(file_path)
# Serve index.html as bytes — avoids Content-Length mismatch
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
content = _inject_bootstrap(content, app)
return Response(
content = content,
media_type = "text/html",
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
)
return _build_index_response()
return True

View file

@ -37,7 +37,10 @@ class AuthStatusResponse(BaseModel):
initialized: bool = Field(
..., description = "True if the auth database contains a login user"
)
default_username: str = Field(..., description = "Default seeded admin username")
default_username: str = Field(
"unsloth",
description = "Default admin username for first-boot UI prefill.",
)
requires_password_change: bool = Field(
...,
description = "True if the seeded admin must still change the default password",

View file

@ -5,10 +5,36 @@
Pydantic schemas for Export API.
"""
from pydantic import BaseModel, Field
from pathlib import Path
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional, Literal, Dict, Any
def _validate_save_directory(value: str) -> str:
"""Reject save_directory values that escape the export root."""
if value is None:
raise ValueError("save_directory is required")
raw = str(value).strip()
if not raw:
raise ValueError("save_directory must not be empty")
if "\x00" in raw:
raise ValueError("save_directory may not contain null bytes")
if any(ch in raw for ch in ("\r", "\n")):
raise ValueError("save_directory may not contain control characters")
if len(raw) > 255:
raise ValueError("save_directory must be <= 255 characters")
path = Path(raw).expanduser()
if path.is_absolute():
raise ValueError(
"save_directory must be a name or relative path under the "
"export root; absolute paths are rejected"
)
if ".." in path.parts:
raise ValueError("save_directory may not contain '..' segments")
return raw
class LoadCheckpointRequest(BaseModel):
"""Request for loading a checkpoint into the export backend."""
@ -64,6 +90,12 @@ class ExportCommonOptions(BaseModel):
...,
description = "Local directory where the exported artifacts will be written",
)
@field_validator("save_directory", mode = "before")
@classmethod
def _check_save_directory(cls, v):
return _validate_save_directory(v)
push_to_hub: bool = Field(
False,
description = "If True, also push the exported model to the Hugging Face Hub",
@ -108,6 +140,12 @@ class ExportGGUFRequest(BaseModel):
...,
description = "Directory where GGUF files will be saved",
)
@field_validator("save_directory", mode = "before")
@classmethod
def _check_save_directory(cls, v):
return _validate_save_directory(v)
quantization_method: str = Field(
"Q4_K_M",
description = 'GGUF quantization method (e.g. "Q4_K_M")',

View file

@ -425,14 +425,6 @@ class ChatMessage(BaseModel):
@model_validator(mode = "after")
def _validate_role_shape(self) -> "ChatMessage":
# Enforce the per-role OpenAI spec shape at the request boundary.
# Without this, malformed messages (e.g. user entries with no
# content, tool_calls on a user/system role, role="tool" without
# tool_call_id) would be silently forwarded to llama-server via
# the passthrough path, surfacing as opaque upstream errors or
# broken tool-call reconciliation downstream.
# Tool-call metadata must appear only on the appropriate role.
if self.tool_calls is not None and self.role != "assistant":
raise ValueError('"tool_calls" is only valid on role="assistant" messages.')
if self.tool_call_id is not None and self.role != "tool":
@ -440,23 +432,20 @@ class ChatMessage(BaseModel):
if self.name is not None and self.role != "tool":
raise ValueError('"name" is only valid on role="tool" messages.')
# Per-role content requirements. OpenAI-compatible clients may send
# ``content=""`` for image-only turns when the image travels in a
# companion field such as Studio's ``image_base64`` extension, so treat
# empty strings as present content for user/system messages.
if self.role == "tool":
if not self.tool_call_id:
raise ValueError(
'role="tool" messages require "tool_call_id" per the OpenAI spec.'
)
# Frontend's second-round POST drops the streamed id;
# synthesise one so the request round-trips.
import secrets as _secrets
self.tool_call_id = f"call_{_secrets.token_hex(8)}"
if not self.content:
raise ValueError('role="tool" messages require non-empty "content".')
elif self.role == "assistant":
# Assistant messages may omit content when tool_calls is set.
if not self.content and not self.tool_calls:
raise ValueError(
'role="assistant" messages require either "content" or "tool_calls".'
)
# Tolerate the post-Stop empty-assistant sentinel by
# collapsing content="" to None.
if (self.content == "" or self.content == []) and not self.tool_calls:
self.content = None
else: # "user" | "system"
if self.content is None or self.content == []:
raise ValueError(f'role="{self.role}" messages require "content".')
@ -542,9 +531,11 @@ class ChatCompletionRequest(BaseModel):
None,
description = "[x-unsloth] Enable/disable thinking/reasoning mode for supported models",
)
reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
reasoning_effort: Optional[
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
] = Field(
None,
description = "[x-unsloth] Reasoning effort level ('low'|'medium'|'high') for Harmony-style reasoning models (e.g. gpt-oss). Overrides enable_thinking when the active model uses reasoning_effort style.",
description = "[x-unsloth] Reasoning effort level ('none'|'minimal'|'low'|'medium'|'high'|'max'|'xhigh'). OpenAI `/v1/responses` accepts model-dependent subsets; Anthropic adaptive thinking uses `max` as the top tier on Claude 4.6 Opus/Sonnet (inbound `xhigh` is mapped to `max`) and `xhigh` on Claude 4.7 Opus; local Harmony/gpt-oss templates support low|medium|high.",
)
preserve_thinking: Optional[bool] = Field(
None,
@ -581,6 +572,28 @@ class ChatCompletionRequest(BaseModel):
description = "[x-unsloth] Per-request cancellation token. Frontend sends a fresh UUID per run so /inference/cancel matches one specific generation.",
)
# ── External provider routing (x-unsloth extensions) ──────────
provider_id: Optional[str] = Field(
None,
description = "[x-unsloth] Saved provider config ID. If set with encrypted_api_key, routes to external LLM.",
)
provider_type: Optional[str] = Field(
None,
description = "[x-unsloth] Provider type (e.g. 'openai', 'mistral'). Used if provider_id is not set.",
)
external_model: Optional[str] = Field(
None,
description = "[x-unsloth] Model ID at the external provider.",
)
encrypted_api_key: Optional[str] = Field(
None,
description = "[x-unsloth] RSA-encrypted, base64-encoded API key for the external provider.",
)
provider_base_url: Optional[str] = Field(
None,
description = "[x-unsloth] Override base URL for the external provider.",
)
# ── Streaming response chunks ────────────────────────────────────

View file

@ -0,0 +1,128 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Pydantic schemas for the external LLM providers API.
"""
from typing import Literal, Optional
from pydantic import BaseModel, Field
# ── Registry (static provider info) ───────────────────────────────
class ProviderRegistryEntry(BaseModel):
"""A supported provider type with its default configuration."""
provider_type: str = Field(
..., description = "Provider identifier (e.g. 'openai', 'mistral')"
)
display_name: str = Field(..., description = "Human-readable provider name")
base_url: str = Field(..., description = "Default API base URL")
default_models: list[str] = Field(
default_factory = list, description = "Well-known model IDs for this provider"
)
supports_streaming: bool = Field(
True, description = "Whether this provider supports SSE streaming"
)
supports_vision: bool = Field(
False, description = "Whether this provider supports vision/image input"
)
supports_tool_calling: bool = Field(
False, description = "Whether this provider supports tool/function calling"
)
model_list_mode: Literal["remote", "curated"] = Field(
"remote",
description = "remote = fetch /models; curated = huge catalogs — UI uses defaults + manual IDs only",
)
# ── Provider config CRUD ──────────────────────────────────────────
class ProviderCreate(BaseModel):
"""Request to create a saved provider configuration."""
provider_type: str = Field(..., description = "Provider type from the registry")
display_name: str = Field(
..., description = "User-chosen label (e.g. 'My OpenAI Key')"
)
base_url: Optional[str] = Field(
None,
description = "Custom base URL (overrides registry default). Omit to use the default.",
)
class ProviderUpdate(BaseModel):
"""Request to update a saved provider configuration."""
display_name: Optional[str] = Field(None, description = "New display name")
base_url: Optional[str] = Field(None, description = "New base URL")
is_enabled: Optional[bool] = Field(
None, description = "Enable or disable this provider"
)
class ProviderResponse(BaseModel):
"""A saved provider configuration (returned by list/get endpoints)."""
id: str = Field(..., description = "Unique provider config ID")
provider_type: str = Field(..., description = "Provider type (e.g. 'openai')")
display_name: str = Field(..., description = "User-chosen label")
base_url: str = Field(..., description = "API base URL")
is_enabled: bool = Field(True, description = "Whether this provider is enabled")
created_at: str = Field(..., description = "ISO 8601 creation timestamp")
updated_at: str = Field(..., description = "ISO 8601 last-update timestamp")
# ── Model listing ─────────────────────────────────────────────────
class ProviderModelInfo(BaseModel):
"""A model available from an external provider."""
id: str = Field(..., description = "Model ID as expected by the provider API")
display_name: str = Field("", description = "Human-readable model name")
context_length: Optional[int] = Field(
None, description = "Maximum context length in tokens"
)
owned_by: Optional[str] = Field(None, description = "Model owner/organization")
class ProviderModelsRequest(BaseModel):
"""Request to list models from an external provider."""
provider_type: str = Field(..., description = "Provider type from the registry")
encrypted_api_key: str = Field(
..., description = "RSA-encrypted, base64-encoded API key"
)
base_url: Optional[str] = Field(
None, description = "Custom base URL (overrides registry default)"
)
# ── Connection testing ────────────────────────────────────────────
class ProviderTestRequest(BaseModel):
"""Request to test connectivity to an external provider."""
provider_type: str = Field(..., description = "Provider type from the registry")
encrypted_api_key: str = Field(
..., description = "RSA-encrypted, base64-encoded API key"
)
base_url: Optional[str] = Field(
None, description = "Custom base URL (overrides registry default)"
)
class ProviderTestResult(BaseModel):
"""Result of a provider connectivity test."""
success: bool = Field(..., description = "Whether the test succeeded")
message: str = Field(..., description = "Human-readable result message")
models_count: Optional[int] = Field(
None, description = "Number of models found (if test succeeded)"
)

View file

@ -5,10 +5,43 @@
Pydantic schemas for Training API
"""
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Any, Optional, List, Dict, Literal
_MAX_BATCH_SIZE = 4096
_MAX_GRAD_ACCUM = 4096
_MAX_STEPS = 1_000_000
_MAX_EPOCHS = 1000
# 2M is a sanity cap; host RAM runs out long before this.
_MAX_SEQ_LENGTH = 2_000_000
_MAX_LR_VALUE = 1.0
_MAX_LORA_R = 16_384
_MAX_LORA_ALPHA = 32_768
def _parse_lr(v: Any) -> float:
"""Parse learning_rate as a positive float strictly below _MAX_LR_VALUE."""
if v is None:
raise ValueError("learning_rate is required")
if isinstance(v, bool):
raise ValueError("learning_rate must be a number, not a bool")
try:
lr = float(v)
except (TypeError, ValueError):
raise ValueError(f"learning_rate must be parseable as float (got {v!r})")
if not (lr > 0.0):
raise ValueError(
f"learning_rate must be > 0 (got {lr!r}); " "typical range is 1e-6 .. 1e-3"
)
if lr >= _MAX_LR_VALUE:
raise ValueError(
f"learning_rate must be < 1.0 (got {lr!r}); "
"values that large always diverge training"
)
return lr
class TrainingStartRequest(BaseModel):
"""Request schema for starting training"""
@ -64,6 +97,150 @@ class TrainingStartRequest(BaseModel):
values.setdefault("train_split", values.pop("split"))
return values
@field_validator("learning_rate", mode = "before")
@classmethod
def _check_learning_rate(cls, v):
# Stringify because downstream call sites float() it themselves.
lr = _parse_lr(v)
return str(lr)
@field_validator("batch_size")
@classmethod
def _check_batch_size(cls, v: int) -> int:
if v is None:
raise ValueError("batch_size is required")
if v < 1 or v > _MAX_BATCH_SIZE:
raise ValueError(
f"batch_size must be in [1, {_MAX_BATCH_SIZE}] (got {v!r})"
)
return v
@field_validator("gradient_accumulation_steps")
@classmethod
def _check_grad_accum(cls, v: int) -> int:
if v is None:
return 1
if v < 1 or v > _MAX_GRAD_ACCUM:
raise ValueError(
f"gradient_accumulation_steps must be in [1, {_MAX_GRAD_ACCUM}] "
f"(got {v!r})"
)
return v
@field_validator("num_epochs")
@classmethod
def _check_num_epochs(cls, v: int) -> int:
# 0 is a sentinel meaning "use max_steps instead"; the frontend's
# steps-vs-epochs toggle sends it.
if v is None:
return 1
if v < 0 or v > _MAX_EPOCHS:
raise ValueError(f"num_epochs must be in [0, {_MAX_EPOCHS}] (got {v!r})")
return v
@field_validator("max_steps")
@classmethod
def _check_max_steps(cls, v: Optional[int]) -> Optional[int]:
# 0 is the frontend's sentinel for "use num_epochs instead".
if v is None:
return v
if not isinstance(v, int) or v < 0 or v > _MAX_STEPS:
raise ValueError(
f"max_steps must be a non-negative int <= {_MAX_STEPS} (got {v!r})"
)
return v
@field_validator("max_seq_length")
@classmethod
def _check_max_seq_length(cls, v: int) -> int:
if v is None or v < 1 or v > _MAX_SEQ_LENGTH:
raise ValueError(
f"max_seq_length must be in [1, {_MAX_SEQ_LENGTH}] (got {v!r})"
)
return v
@field_validator("warmup_steps")
@classmethod
def _check_warmup_steps(cls, v: Optional[int]) -> Optional[int]:
if v is None:
return v
if not isinstance(v, int) or v < 0 or v > _MAX_STEPS:
raise ValueError(
f"warmup_steps must be a non-negative int <= {_MAX_STEPS} "
f"(got {v!r})"
)
return v
@field_validator("warmup_ratio")
@classmethod
def _check_warmup_ratio(cls, v):
if v is None:
return v
try:
r = float(v)
except (TypeError, ValueError):
raise ValueError(f"warmup_ratio must be a number (got {v!r})")
if not (0.0 <= r <= 1.0):
raise ValueError(f"warmup_ratio must be in [0.0, 1.0] (got {r!r})")
return r
@field_validator("save_steps")
@classmethod
def _check_save_steps(cls, v: int) -> int:
if v is None:
return 100
if v < 0 or v > _MAX_STEPS:
raise ValueError(f"save_steps must be in [0, {_MAX_STEPS}] (got {v!r})")
return v
@field_validator("weight_decay")
@classmethod
def _check_weight_decay(cls, v: float) -> float:
if v is None:
return 0.0
try:
wd = float(v)
except (TypeError, ValueError):
raise ValueError(f"weight_decay must be a number (got {v!r})")
if wd < 0 or wd > 10.0:
raise ValueError(
f"weight_decay must be in [0, 10] (got {wd!r}); typical 0..0.1"
)
return wd
@field_validator("lora_r")
@classmethod
def _check_lora_r(cls, v: int) -> int:
if v is None:
return 16
if v < 1 or v > _MAX_LORA_R:
raise ValueError(f"lora_r must be in [1, {_MAX_LORA_R}] (got {v!r})")
return v
@field_validator("lora_alpha")
@classmethod
def _check_lora_alpha(cls, v: int) -> int:
if v is None:
return 16
if v < 1 or v > _MAX_LORA_ALPHA:
raise ValueError(
f"lora_alpha must be in [1, {_MAX_LORA_ALPHA}] (got {v!r})"
)
return v
@field_validator("lora_dropout")
@classmethod
def _check_lora_dropout(cls, v: float) -> float:
if v is None:
return 0.0
try:
d = float(v)
except (TypeError, ValueError):
raise ValueError(f"lora_dropout must be a number (got {v!r})")
if not (0.0 <= d < 1.0):
raise ValueError(f"lora_dropout must be in [0.0, 1.0) (got {d!r})")
return d
custom_format_mapping: Optional[Dict[str, Any]] = Field(
None,
description = (
@ -85,6 +262,11 @@ class TrainingStartRequest(BaseModel):
max_steps: Optional[int] = Field(None, description = "Maximum training steps")
save_steps: int = Field(100, description = "Steps between checkpoints")
weight_decay: float = Field(0.001, description = "Weight decay")
max_grad_norm: float = Field(
0.0,
ge = 0,
description = "Global gradient norm clipping threshold. Set 0 to disable.",
)
random_seed: int = Field(42, description = "Random seed")
packing: bool = Field(False, description = "Enable sequence packing")
optim: str = Field("adamw_8bit", description = "Optimizer")
@ -147,6 +329,16 @@ class TrainingStartRequest(BaseModel):
description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries.",
)
@model_validator(mode = "after")
def _check_steps_or_epochs(self) -> "TrainingStartRequest":
# num_epochs and max_steps each accept 0 as a "use the other one"
# sentinel. If both resolve to 0 there's nothing to train against.
if (self.max_steps is None or self.max_steps == 0) and self.num_epochs == 0:
raise ValueError(
"Either num_epochs or max_steps must be > 0; both cannot be 0."
)
return self
class TrainingJobResponse(BaseModel):
"""Immediate response when training is initiated"""

View file

@ -8,7 +8,28 @@
# unsloth direct deps (from pyproject.toml [project].dependencies)
typer
# typer's full runtime dep tree. Required explicitly because this
# file is installed with --no-deps. On Linux/Mac CI runners these
# are often cached transitively; on a fresh windows-latest venv they
# are not, and `unsloth studio setup` crashes with
# `ModuleNotFoundError: No module named 'click'`, then 'annotated_doc',
# then 'rich', etc. as each is hit. Pin the full chain so the
# no-torch path works cleanly on every fresh venv.
click>=8.0
shellingham>=1.5
annotated-doc>=0.0.3
rich>=13.0
markdown-it-py>=3.0
mdurl>=0.1
pygments>=2.0
pydantic
# pydantic 2.x deps. With --no-deps, `import pydantic` blows up
# with `ModuleNotFoundError: 'pydantic_core'` (compiled Rust core,
# separate wheel), then `'annotated_types'`, then
# `'typing_inspection'` (used by pydantic 2.10+ for fields).
pydantic-core
annotated-types>=0.6
typing-inspection>=0.4
pyyaml
nest-asyncio
@ -42,7 +63,9 @@ anyio
sniffio
h11
tokenizers
# Unpinned resolves to 0.23.1+ which breaks `from transformers import
# AutoConfig`; transformers 4.56..5.3 declares tokenizers<=0.23.0.
tokenizers<=0.23.0
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.3.0
trl>=0.18.2,!=0.19.0,<=0.24.0
sentence-transformers

View file

@ -3,6 +3,7 @@ typer
fastapi
uvicorn
pydantic
packaging
matplotlib
pandas
nest_asyncio
@ -15,3 +16,5 @@ huggingface-hub==0.36.2
structlog>=24.1.0
diceware
ddgs
cryptography>=42.0.0
httpx>=0.27.0

View file

@ -14,6 +14,7 @@ from routes.auth import router as auth_router
from routes.data_recipe import router as data_recipe_router
from routes.export import router as export_router
from routes.training_history import router as training_history_router
from routes.providers import router as providers_router
__all__ = [
"training_router",
@ -25,4 +26,5 @@ __all__ = [
"data_recipe_router",
"export_router",
"training_history_router",
"providers_router",
]

View file

@ -5,8 +5,11 @@
Authentication API routes
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
import threading
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from models.auth import (
@ -33,14 +36,52 @@ from auth.authentication import (
router = APIRouter()
# In-memory per-IP login rate limiter; multi-process deployment needs a shared store.
_LOGIN_BUCKETS: dict[str, deque] = {}
_LOGIN_BUCKETS_LOCK = threading.Lock()
_LOGIN_WINDOW_SECONDS = 60.0
_LOGIN_MAX_FAILS = 5
_LOGIN_LOCKOUT_SECONDS = 60
def _client_key(request: Request | None) -> str:
if request is None or request.client is None:
return "_unknown"
return request.client.host or "_unknown"
def _record_login_failure(ip: str) -> int:
now = time.monotonic()
with _LOGIN_BUCKETS_LOCK:
bucket = _LOGIN_BUCKETS.setdefault(ip, deque())
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
bucket.append(now)
return len(bucket)
def _login_blocked(ip: str) -> int:
"""Return seconds until the next attempt is allowed, or 0."""
now = time.monotonic()
with _LOGIN_BUCKETS_LOCK:
bucket = _LOGIN_BUCKETS.get(ip)
if not bucket:
return 0
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
if len(bucket) >= _LOGIN_MAX_FAILS:
return max(1, int(_LOGIN_WINDOW_SECONDS - (now - bucket[0])))
return 0
def _clear_login_bucket(ip: str) -> None:
with _LOGIN_BUCKETS_LOCK:
_LOGIN_BUCKETS.pop(ip, None)
@router.get("/status", response_model = AuthStatusResponse)
async def auth_status() -> AuthStatusResponse:
"""
Check whether auth has already been initialized.
- initialized = False -> frontend should wait for the seeded admin bootstrap.
- initialized = True -> frontend should show login or force the first password change.
"""
"""Auth initialization state; ``default_username`` is exposed for first-boot UI prefill only."""
return AuthStatusResponse(
initialized = storage.is_initialized(),
default_username = storage.DEFAULT_ADMIN_USERNAME,
@ -53,12 +94,23 @@ async def auth_status() -> AuthStatusResponse:
@router.post("/login", response_model = Token)
async def login(payload: AuthLoginRequest) -> Token:
"""
Login with username/password and receive access + refresh tokens.
"""
async def login(payload: AuthLoginRequest, request: Request) -> Token:
"""Login with username/password. Rate-limited per source IP."""
ip = _client_key(request)
blocked_for = _login_blocked(ip)
if blocked_for > 0:
raise HTTPException(
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
detail = (
f"Too many failed login attempts from {ip}. "
f"Try again in {blocked_for} seconds."
),
headers = {"Retry-After": str(blocked_for)},
)
record = storage.get_user_and_secret(payload.username)
if record is None:
_record_login_failure(ip)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
@ -66,11 +118,13 @@ async def login(payload: AuthLoginRequest) -> Token:
salt, pwd_hash, _jwt_secret, must_change_password = record
if not hashing.verify_password(payload.password, salt, pwd_hash):
_record_login_failure(ip)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
)
_clear_login_bucket(ip)
access_token = create_access_token(subject = payload.username)
refresh_token = create_refresh_token(subject = payload.username)
return Token(
@ -81,6 +135,23 @@ async def login(payload: AuthLoginRequest) -> Token:
)
@router.post("/logout", status_code = status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
current_subject: str = Depends(get_current_subject_allow_password_change),
) -> Response:
"""Revoke refresh tokens for the subject; the access token is stateless and expires on its own."""
try:
storage.revoke_user_refresh_tokens(current_subject)
except Exception:
pass
try:
request.app.state.bootstrap_password = None
except AttributeError:
pass
return Response(status_code = status.HTTP_204_NO_CONTENT)
@router.post("/desktop-login", response_model = Token)
async def desktop_login(payload: DesktopLoginRequest) -> Token:
"""Exchange a local desktop secret for normal admin-subject tokens."""
@ -101,21 +172,20 @@ async def desktop_login(payload: DesktopLoginRequest) -> Token:
@router.post("/refresh", response_model = Token)
async def refresh(payload: RefreshTokenRequest) -> Token:
"""
Exchange a valid refresh token for a new access token.
The refresh token itself is reusable until it expires (7 days).
"""
new_access_token, username, is_desktop = refresh_access_token(payload.refresh_token)
if new_access_token is None or username is None:
"""Exchange a refresh token for a new access+refresh pair (single-use)."""
consumed = storage.consume_refresh_token(payload.refresh_token)
if consumed is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired refresh token",
)
username, is_desktop = consumed
new_access_token = create_access_token(subject = username, desktop = is_desktop)
new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop)
return Token(
access_token = new_access_token,
refresh_token = payload.refresh_token,
refresh_token = new_refresh_token,
token_type = "bearer",
must_change_password = False
if is_desktop
@ -126,6 +196,7 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
@router.post("/change-password", response_model = Token)
async def change_password(
payload: ChangePasswordRequest,
request: Request,
current_subject: str = Depends(get_current_subject_allow_password_change),
) -> Token:
"""Allow the authenticated user to replace the default password."""
@ -150,6 +221,10 @@ async def change_password(
storage.update_password(current_subject, payload.new_password)
storage.revoke_user_refresh_tokens(current_subject)
try:
request.app.state.bootstrap_password = None
except AttributeError:
pass
access_token = create_access_token(subject = current_subject)
refresh_token = create_refresh_token(subject = current_subject)
return Token(

View file

@ -7,6 +7,7 @@ Export API routes: checkpoint discovery and model export operations.
import asyncio
import json
import os
import sys
import time
from pathlib import Path
@ -184,14 +185,18 @@ async def get_export_status(
def _export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
"""Wrap the resolved on-disk export path into the details dict the
frontend reads to populate the Export Complete screen. Returns None
when the export had no local component (Hub-only push) so the
Pydantic field stays absent rather than ``{"output_path": null}``.
"""
"""Return the export path relative to exports_root so the install path is not leaked."""
if not output_path:
return None
return {"output_path": output_path}
try:
from utils.paths.storage_roots import exports_root
rel = os.path.relpath(output_path, exports_root())
if rel.startswith(".."):
rel = os.path.basename(output_path)
return {"output_path": rel}
except Exception:
return {"output_path": os.path.basename(output_path)}
@router.post("/export/merged", response_model = ExportOperationResponse)

View file

@ -204,6 +204,11 @@ from core.inference.anthropic_compat import (
)
from auth.authentication import get_current_subject
from core.inference.key_exchange import decrypt_api_key
from core.inference.providers import get_provider_info, get_base_url
from core.inference.external_provider import ExternalProviderClient
from storage import providers_db
import io
import wave
import base64
@ -1464,6 +1469,161 @@ def _extract_content_parts(
return system_prompt, chat_messages, first_image_b64
# ── External provider proxy ──────────────────────────────────────
def _build_external_messages(
messages: list,
supports_vision: bool,
) -> list[dict]:
"""
Convert ChatMessage list to OpenAI-compatible dicts for external providers.
- Vision providers: preserve multimodal content arrays (image_url parts intact).
- Non-vision providers: flatten to text-only (images silently dropped).
"""
result = []
for msg in messages:
if isinstance(msg.content, str):
# Skip assistant messages with empty content (some providers reject them)
if msg.role == "assistant" and not msg.content.strip():
continue
result.append({"role": msg.role, "content": msg.content})
elif isinstance(msg.content, list):
if supports_vision:
parts = []
for part in msg.content:
if part.type == "text":
parts.append({"type": "text", "text": part.text})
elif part.type == "image_url":
parts.append(
{
"type": "image_url",
"image_url": {"url": part.image_url.url},
}
)
result.append({"role": msg.role, "content": parts})
else:
# Non-vision provider — strip images, keep text only
text = "\n".join(p.text for p in msg.content if p.type == "text")
result.append({"role": msg.role, "content": text})
return result
async def _proxy_to_external_provider(
payload: ChatCompletionRequest,
request: Request,
) -> StreamingResponse:
"""
Proxy a chat completion request to an external LLM provider.
Resolves provider config (from DB or registry), decrypts the API key,
and streams the response back in OpenAI SSE format.
"""
# Resolve provider type and base URL
provider_type = payload.provider_type
base_url = payload.provider_base_url
if payload.provider_id:
config = providers_db.get_provider(payload.provider_id)
if config is None:
raise HTTPException(
status_code = 404,
detail = f"Provider config not found: {payload.provider_id}",
)
if not config["is_enabled"]:
raise HTTPException(
status_code = 400,
detail = f"Provider '{config['display_name']}' is disabled.",
)
provider_type = provider_type or config["provider_type"]
base_url = base_url or config["base_url"]
if not provider_type:
raise HTTPException(
status_code = 400,
detail = "Either provider_id or provider_type is required for external provider routing.",
)
# Fall back to registry default base URL
if not base_url:
base_url = get_base_url(provider_type)
if not base_url:
raise HTTPException(
status_code = 400,
detail = f"Unknown provider type: {provider_type}",
)
# Decrypt the API key
try:
api_key = decrypt_api_key(payload.encrypted_api_key)
except Exception as exc:
logger.warning("external_provider.decrypt_failed", error = str(exc))
raise HTTPException(
status_code = 400,
detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.",
)
model = payload.external_model or payload.model
if model == "default":
raise HTTPException(
status_code = 400,
detail = "external_model is required when using an external provider.",
)
# Build messages preserving multimodal content for vision-capable providers
from core.inference.providers import get_provider_info as _get_provider_info
_pinfo = _get_provider_info(provider_type) or {}
_supports_vision = _pinfo.get("supports_vision", False)
chat_messages = _build_external_messages(payload.messages, _supports_vision)
client = ExternalProviderClient(
provider_type = provider_type,
base_url = base_url,
api_key = api_key,
)
async def _stream():
gen = client.stream_chat_completion(
messages = chat_messages,
model = model,
temperature = payload.temperature,
top_p = payload.top_p,
max_tokens = payload.max_tokens,
presence_penalty = payload.presence_penalty,
top_k = payload.top_k,
enable_thinking = payload.enable_thinking,
reasoning_effort = payload.reasoning_effort,
stream = payload.stream,
)
try:
sent_done = False
async for line in gen:
yield f"{line}\n\n"
if "[DONE]" in line:
sent_done = True
if not sent_done:
yield "data: [DONE]\n\n"
except Exception as exc:
logger.error("external_provider.stream_error", error = str(exc))
finally:
try:
await gen.aclose()
except RuntimeError:
pass # suppress httpcore asyncgen cleanup error (Python 3.13 + httpcore 1.0.x)
await client.close()
return StreamingResponse(
_stream(),
media_type = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@router.post("/chat/completions")
async def openai_chat_completions(
payload: ChatCompletionRequest,
@ -1483,6 +1643,10 @@ async def openai_chat_completions(
- GGUF models llama-server via LlamaCppBackend
- Other models Unsloth/transformers via InferenceBackend
"""
# ── External provider routing ────────────────────────────────
if payload.encrypted_api_key and (payload.provider_id or payload.provider_type):
return await _proxy_to_external_provider(payload, request)
llama_backend = get_llama_cpp_backend()
using_gguf = llama_backend.is_loaded
@ -1743,7 +1907,7 @@ async def openai_chat_completions(
try:
import base64 as _b64
from io import BytesIO as _BytesIO
from PIL import Image as _Image
from PIL import Image as _Image, UnidentifiedImageError as _UIE
raw = _b64.b64decode(image_b64)
# Normalize to RGB so PNG encoding succeeds regardless of
@ -1754,9 +1918,15 @@ async def openai_chat_completions(
buf = _BytesIO()
img.save(buf, format = "PNG")
image_b64 = _b64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
except _UIE:
raise HTTPException(
status_code = 400, detail = f"Failed to process image: {e}"
status_code = 400,
detail = "Unsupported or corrupt image format.",
)
except Exception:
raise HTTPException(
status_code = 400,
detail = "Failed to process image.",
)
# Build message list with system prompt prepended
@ -3426,10 +3596,10 @@ def _normalize_anthropic_openai_images(
buf = io.BytesIO()
img.save(buf, format = "PNG")
png_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
except Exception:
raise HTTPException(
status_code = 400,
detail = f"Failed to process image: {e}",
detail = "Failed to process image.",
)
part["image_url"] = {"url": f"data:image/png;base64,{png_b64}"}
@ -3465,6 +3635,7 @@ async def anthropic_messages(
[m.model_dump() for m in payload.messages],
payload.system,
)
openai_messages = _drop_empty_assistant_sentinels(openai_messages)
# Enforce vision guard + re-encode embedded images to PNG so the
# Anthropic endpoint matches the behavior of /v1/chat/completions.
@ -4190,6 +4361,19 @@ async def _anthropic_passthrough_non_streaming(
# =====================================================================
def _drop_empty_assistant_sentinels(messages: list[dict]) -> list[dict]:
"""Drop bare ``{"role":"assistant"}`` Stop-button sentinels; passthrough backends reject them."""
out: list[dict] = []
for m in messages:
if m.get("role") == "assistant":
has_content = bool(m.get("content"))
has_tool_calls = bool(m.get("tool_calls"))
if not has_content and not has_tool_calls:
continue
out.append(m)
return out
def _openai_messages_for_passthrough(payload) -> list[dict]:
"""Build OpenAI-format message dicts for the /v1/chat/completions
passthrough path.
@ -4206,7 +4390,9 @@ def _openai_messages_for_passthrough(payload) -> list[dict]:
``image_url`` content part so vision + function-calling requests work
transparently.
"""
messages = [m.model_dump(exclude_none = True) for m in payload.messages]
messages = _drop_empty_assistant_sentinels(
[m.model_dump(exclude_none = True) for m in payload.messages]
)
if not payload.image_base64:
return messages
@ -4221,10 +4407,10 @@ def _openai_messages_for_passthrough(payload) -> list[dict]:
buf = _BytesIO()
img.save(buf, format = "PNG")
png_b64 = _b64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
except Exception:
raise HTTPException(
status_code = 400,
detail = f"Failed to process image: {e}",
detail = "Failed to process image.",
)
data_url = f"data:image/png;base64,{png_b64}"

View file

@ -0,0 +1,338 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
API routes for external LLM provider management.
Provides endpoints for:
- Discovering available provider types (registry)
- CRUD for saved provider configurations (no API keys stored)
- Fetching the RSA public key for API key encryption
- Testing provider connectivity
- Listing models from a provider
"""
import uuid
import structlog
from fastapi import APIRouter, Depends, HTTPException
from auth.authentication import get_current_subject
from core.inference.key_exchange import (
decrypt_api_key,
get_public_key_fingerprint,
get_public_key_pem,
)
from core.inference.providers import (
get_base_url,
get_provider_info,
list_available_providers,
)
from core.inference.external_provider import ExternalProviderClient
from models.providers import (
ProviderCreate,
ProviderModelsRequest,
ProviderModelInfo,
ProviderResponse,
ProviderRegistryEntry,
ProviderTestRequest,
ProviderTestResult,
ProviderUpdate,
)
from storage import providers_db
logger = structlog.get_logger(__name__)
router = APIRouter()
# ── Public key for API key encryption ─────────────────────────────
@router.get("/public-key")
async def get_public_key(
current_subject: str = Depends(get_current_subject),
):
"""Return the RSA public key PEM for client-side API key encryption.
The ``fingerprint`` field is a short SHA256 of the PEM and is meant
purely for diagnostics a mismatch between what the frontend
captured at encrypt time and what the server reports here is a
clear signal that the keypair rotated mid-flight (e.g. the server
re-ran ``init_key_pair`` for any reason).
"""
return {
"public_key": get_public_key_pem(),
"fingerprint": get_public_key_fingerprint(),
}
# ── Provider registry (static) ───────────────────────────────────
@router.get("/registry", response_model = list[ProviderRegistryEntry])
async def list_registry(
current_subject: str = Depends(get_current_subject),
):
"""List all supported provider types with their default configurations."""
return list_available_providers()
# ── Provider config CRUD ──────────────────────────────────────────
@router.get("/", response_model = list[ProviderResponse])
async def list_provider_configs(
current_subject: str = Depends(get_current_subject),
):
"""List all saved provider configurations."""
rows = providers_db.list_providers()
return [
ProviderResponse(
id = row["id"],
provider_type = row["provider_type"],
display_name = row["display_name"],
base_url = row["base_url"],
is_enabled = bool(row["is_enabled"]),
created_at = row["created_at"],
updated_at = row["updated_at"],
)
for row in rows
]
@router.post("/", response_model = ProviderResponse, status_code = 201)
async def create_provider_config(
payload: ProviderCreate,
current_subject: str = Depends(get_current_subject),
):
"""Create a new saved provider configuration (no API key stored)."""
info = get_provider_info(payload.provider_type)
if info is None:
raise HTTPException(
status_code = 400,
detail = f"Unknown provider type: {payload.provider_type}. "
f"Use GET /api/providers/registry to see available types.",
)
provider_id = uuid.uuid4().hex[:16]
base_url = payload.base_url or info["base_url"]
providers_db.create_provider(
id = provider_id,
provider_type = payload.provider_type,
display_name = payload.display_name,
base_url = base_url,
)
row = providers_db.get_provider(provider_id)
return ProviderResponse(
id = row["id"],
provider_type = row["provider_type"],
display_name = row["display_name"],
base_url = row["base_url"],
is_enabled = bool(row["is_enabled"]),
created_at = row["created_at"],
updated_at = row["updated_at"],
)
@router.put("/{provider_id}", response_model = ProviderResponse)
async def update_provider_config(
provider_id: str,
payload: ProviderUpdate,
current_subject: str = Depends(get_current_subject),
):
"""Update a saved provider configuration."""
existing = providers_db.get_provider(provider_id)
if not existing:
raise HTTPException(status_code = 404, detail = "Provider not found")
updated = providers_db.update_provider(
id = provider_id,
display_name = payload.display_name,
base_url = payload.base_url,
is_enabled = payload.is_enabled,
)
if not updated:
raise HTTPException(status_code = 400, detail = "No fields to update")
row = providers_db.get_provider(provider_id)
return ProviderResponse(
id = row["id"],
provider_type = row["provider_type"],
display_name = row["display_name"],
base_url = row["base_url"],
is_enabled = bool(row["is_enabled"]),
created_at = row["created_at"],
updated_at = row["updated_at"],
)
@router.delete("/{provider_id}", status_code = 204)
async def delete_provider_config(
provider_id: str,
current_subject: str = Depends(get_current_subject),
):
"""Delete a saved provider configuration."""
deleted = providers_db.delete_provider(provider_id)
if not deleted:
raise HTTPException(status_code = 404, detail = "Provider not found")
# ── Test connectivity ─────────────────────────────────────────────
@router.post("/test", response_model = ProviderTestResult)
async def test_provider(
payload: ProviderTestRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Test connectivity to an external provider.
Makes a lightweight GET /models call to verify the API key works.
The encrypted_api_key is decrypted server-side and never stored.
"""
info = get_provider_info(payload.provider_type)
if info is None:
raise HTTPException(
status_code = 400,
detail = f"Unknown provider type: {payload.provider_type}",
)
try:
api_key = decrypt_api_key(payload.encrypted_api_key)
except Exception as exc:
logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc)
raise HTTPException(
status_code = 400,
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
)
base_url = payload.base_url or info["base_url"]
client = ExternalProviderClient(
provider_type = payload.provider_type,
base_url = base_url,
api_key = api_key,
timeout = 15.0,
)
try:
if info.get("model_list_mode") == "curated":
await client.verify_models_endpoint_lightweight()
return ProviderTestResult(
success = True,
message = (
"Connected successfully. Full model list is not fetched for this provider — "
"use suggestions and manual model IDs in the dialog."
),
models_count = None,
)
models = await client.list_models()
return ProviderTestResult(
success = True,
message = f"Connected successfully. Found {len(models)} model(s).",
models_count = len(models),
)
except Exception as exc:
logger.warning("Provider test failed for %s: %s", payload.provider_type, exc)
return ProviderTestResult(
success = False,
message = f"Connection failed: {exc}",
models_count = None,
)
finally:
await client.close()
# ── List models from provider ─────────────────────────────────────
@router.post("/models", response_model = list[ProviderModelInfo])
async def list_provider_models(
payload: ProviderModelsRequest,
current_subject: str = Depends(get_current_subject),
):
"""
List models available from an external provider.
The encrypted_api_key is decrypted server-side and never stored.
"""
info = get_provider_info(payload.provider_type)
if info is None:
raise HTTPException(
status_code = 400,
detail = f"Unknown provider type: {payload.provider_type}",
)
try:
api_key = decrypt_api_key(payload.encrypted_api_key)
except Exception as exc:
logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc)
raise HTTPException(
status_code = 400,
detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.",
)
if info.get("model_list_mode") == "curated":
return [
ProviderModelInfo(
id = m,
display_name = m,
context_length = None,
owned_by = None,
)
for m in info.get("default_models", [])
]
base_url = payload.base_url or info["base_url"]
client = ExternalProviderClient(
provider_type = payload.provider_type,
base_url = base_url,
api_key = api_key,
timeout = 15.0,
)
try:
models = await client.list_models()
allow_prefixes = info.get("model_id_allow_prefixes")
if allow_prefixes is not None:
prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p))
if prefix_tuple:
models = [m for m in models if m.get("id", "").startswith(prefix_tuple)]
allowlist = info.get("model_id_allowlist")
if allowlist is not None:
models = [m for m in models if allowlist.match(m.get("id", ""))]
deny_exact = info.get("model_id_deny_exact")
if deny_exact is not None:
deny_ids = {str(m) for m in deny_exact if str(m)}
if deny_ids:
models = [m for m in models if m.get("id", "") not in deny_ids]
denylist = info.get("model_id_denylist")
if denylist is not None:
models = [m for m in models if not denylist.search(m.get("id", ""))]
# Apply an optional cap after filtering so registry entries with a
# large remote catalog (e.g. HF Inference Providers) can stay
# picker-sized. No popularity sort happens server-side, so this is
# "first N matches" — pair with default_models for any must-have
# flagship ids.
limit = info.get("model_id_limit")
if isinstance(limit, int) and limit > 0:
models = models[:limit]
return [
ProviderModelInfo(
id = m.get("id", ""),
display_name = m.get("id", ""),
context_length = m.get("context_length") or m.get("context_window"),
owned_by = m.get("owned_by"),
)
for m in models
]
except Exception as exc:
logger.error("Failed to list models from %s: %s", payload.provider_type, exc)
raise HTTPException(
status_code = 502,
detail = f"Failed to list models from {payload.provider_type}: {exc}",
)
finally:
await client.close()

View file

@ -215,6 +215,7 @@ async def start_training(
"max_steps": request.max_steps,
"save_steps": request.save_steps,
"weight_decay": request.weight_decay,
"max_grad_norm": request.max_grad_norm,
"random_seed": request.random_seed,
"packing": request.packing,
"optim": request.optim,

View file

@ -307,7 +307,6 @@ def run_server(
import asyncio
from threading import Thread, Event
import time
import uvicorn
from main import app, setup_frontend
@ -336,10 +335,6 @@ def run_server(
print("=" * 50)
print("")
# Output port for Tauri to parse when in api-only mode
if api_only:
print(f"TAURI_PORT={port}", flush = True)
# Setup frontend if path provided (skip in api-only mode)
if frontend_path and not api_only:
if setup_frontend(app, frontend_path):
@ -349,11 +344,26 @@ def run_server(
if not silent:
print(f"[WARNING] Frontend not found at {frontend_path}")
# Create the uvicorn server and expose it for signal handlers
ready_event = Event()
startup_failed = Event()
startup_errors = []
class _ReadyServer(uvicorn.Server):
async def startup(self, *args, **kwargs):
await super().startup(*args, **kwargs)
if getattr(self, "started", False) and not self.should_exit:
ready_event.set()
# server_header=False suppresses uvicorn's "Server: uvicorn"; SecurityHeadersMiddleware sets its own.
config = uvicorn.Config(
app, host = host, port = port, log_level = "info", access_log = False
app,
host = host,
port = port,
log_level = "info",
access_log = False,
server_header = False,
)
_server = uvicorn.Server(config)
_server = _ReadyServer(config)
_shutdown_event = Event()
# Expose the actual bound port so request-handling code can build
@ -365,21 +375,8 @@ def run_server(
app.state.server_port = port if port and port > 0 else None
app.state.llama_parallel_slots = llama_parallel_slots
# Run server in a daemon thread
def _run():
asyncio.run(_server.serve())
thread = Thread(target = _run, daemon = True)
thread.start()
time.sleep(3)
_write_pid_file()
import atexit
atexit.register(_remove_pid_file)
# Expose a shutdown callable via app.state so the /api/shutdown endpoint
# can trigger graceful shutdown without circular imports.
# Expose a shutdown callable via app.state before the server can accept
# requests so /api/shutdown is available as soon as readiness is published.
def _trigger_shutdown():
_graceful_shutdown(_server)
if _shutdown_event is not None:
@ -387,6 +384,47 @@ def run_server(
app.state.trigger_shutdown = _trigger_shutdown
# Run server in a daemon thread
def _run():
try:
asyncio.run(_server.serve())
except BaseException as exc:
startup_errors.append(exc)
startup_failed.set()
finally:
if not ready_event.is_set():
startup_failed.set()
thread = Thread(target = _run, daemon = True)
thread.start()
# Wait until uvicorn has completed lifespan startup and bound sockets, or
# until the server exits/fails before startup. This intentionally has no
# correctness deadline: a slow but live startup should remain in progress.
try:
while not ready_event.is_set():
if startup_failed.is_set() or not thread.is_alive():
if startup_errors:
raise RuntimeError(
"Uvicorn server failed before startup completed"
) from startup_errors[0]
raise RuntimeError("Uvicorn server exited before startup completed")
ready_event.wait(timeout = 0.1)
except KeyboardInterrupt:
_graceful_shutdown(_server)
_shutdown_event.set()
raise
_write_pid_file()
import atexit
atexit.register(_remove_pid_file)
# Output port for Tauri to parse when in api-only mode. Emit only after
# uvicorn sockets are bound and FastAPI lifespan/startup has completed.
if api_only:
print(f"TAURI_PORT={port}", flush = True)
if not silent:
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
print_studio_access_banner(

View file

@ -0,0 +1,153 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
SQLite storage for external LLM provider configurations.
Follows the same pattern as studio_db.py module-level functions,
raw sqlite3, WAL mode, per-function connections.
NOTE: API keys are NOT stored here. They live only in the browser
(localStorage) and are sent encrypted per-request.
"""
import logging
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Optional
logger = logging.getLogger(__name__)
from utils.paths import studio_db_path, ensure_dir
_schema_lock = threading.Lock()
_schema_ready = False
def _ensure_schema(conn: sqlite3.Connection) -> None:
"""Create the llm_providers table if it doesn't exist. Called once per process."""
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS llm_providers (
id TEXT NOT NULL PRIMARY KEY,
provider_type TEXT NOT NULL,
display_name TEXT NOT NULL,
base_url TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
def get_connection() -> sqlite3.Connection:
"""Open studio.db with WAL mode, create table once per process."""
global _schema_ready
db_path = studio_db_path()
ensure_dir(db_path.parent)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
if not _schema_ready:
with _schema_lock:
if not _schema_ready:
try:
_ensure_schema(conn)
_schema_ready = True
except Exception:
conn.close()
raise
return conn
def create_provider(
id: str,
provider_type: str,
display_name: str,
base_url: str,
) -> None:
"""Insert a new provider configuration."""
now = datetime.now(timezone.utc).isoformat()
conn = get_connection()
try:
conn.execute(
"""
INSERT INTO llm_providers (id, provider_type, display_name, base_url, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(id, provider_type, display_name, base_url, now, now),
)
conn.commit()
finally:
conn.close()
def update_provider(
id: str,
display_name: Optional[str] = None,
base_url: Optional[str] = None,
is_enabled: Optional[bool] = None,
) -> bool:
"""Update fields on an existing provider. Returns True if a row was updated."""
updates = []
params = []
if display_name is not None:
updates.append("display_name = ?")
params.append(display_name)
if base_url is not None:
updates.append("base_url = ?")
params.append(base_url)
if is_enabled is not None:
updates.append("is_enabled = ?")
params.append(1 if is_enabled else 0)
if not updates:
return False
updates.append("updated_at = ?")
params.append(datetime.now(timezone.utc).isoformat())
params.append(id)
conn = get_connection()
try:
cursor = conn.execute(
f"UPDATE llm_providers SET {', '.join(updates)} WHERE id = ?",
params,
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def delete_provider(id: str) -> bool:
"""Delete a provider by ID. Returns True if a row was deleted."""
conn = get_connection()
try:
cursor = conn.execute("DELETE FROM llm_providers WHERE id = ?", (id,))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def get_provider(id: str) -> Optional[dict]:
"""Fetch a single provider by ID."""
conn = get_connection()
try:
row = conn.execute("SELECT * FROM llm_providers WHERE id = ?", (id,)).fetchone()
return dict(row) if row else None
finally:
conn.close()
def list_providers() -> list[dict]:
"""List all provider configurations, ordered by creation time."""
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM llm_providers ORDER BY created_at"
).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()

View file

@ -0,0 +1,404 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Unit tests for the Anthropic extended-thinking translation in
external_provider.
Covers:
- Adaptive-mode request body nests effort under
``output_config: {effort: "<level>"}`` per the Messages API
reference (a top-level ``effort`` field 400s with
"effort: Extra inputs are not permitted").
- Streaming SSE: ``content_block_delta`` with
``delta.type == "thinking_delta"`` is translated into inline
``<think>...</think>`` chat-completion chunks so the frontend's
reasoning-panel pipeline lifts it correctly.
- The ``<think>`` tag closes when the first ``text_delta`` arrives,
on ``content_block_stop``, on ``message_delta``, or on
``message_stop``.
- Thinking is paired with ``temperature=1`` and no ``top_p`` /
``top_k`` on the wire (Anthropic extended-thinking contract).
"""
import asyncio
import json
import httpx
from core.inference import external_provider as ep_mod
from core.inference.external_provider import ExternalProviderClient
def _drive(coro):
return asyncio.new_event_loop().run_until_complete(coro)
async def _collect(agen):
out = []
async for line in agen:
out.append(line)
return out
def _mock_http_client(monkeypatch, handler):
transport = httpx.MockTransport(handler)
monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
def _make_client() -> ExternalProviderClient:
return ExternalProviderClient(
provider_type = "anthropic",
base_url = "https://api.anthropic.com/v1",
api_key = "sk-ant-test",
)
def _anthropic_sse(events: list[dict]) -> bytes:
"""Serialize a list of Messages-API event dicts as an SSE byte stream."""
chunks: list[str] = []
for event in events:
chunks.append(f"event: {event['type']}")
chunks.append(f"data: {json.dumps(event)}")
chunks.append("")
return ("\n".join(chunks) + "\n").encode("utf-8")
def _payloads_from_lines(lines: list[str]) -> list:
out = []
for line in lines:
if not line.startswith("data:"):
continue
raw = line[len("data:") :].strip()
if not raw:
continue
if raw == "[DONE]":
out.append("[DONE]")
else:
out.append(json.loads(raw))
return out
def test_adaptive_thinking_body_uses_output_config_effort_shape(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _anthropic_sse([{"type": "message_stop"}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-6",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
top_k = None,
enable_thinking = None,
reasoning_effort = "medium",
):
pass
await client.close()
_drive(run())
body = captured["body"]
# display=summarized is set explicitly so Opus 4.7 (which defaults to
# "omitted") still emits thinking_delta events for the reasoning panel.
assert body["thinking"] == {"type": "adaptive", "display": "summarized"}
# Documented shape: effort is nested under output_config.
# A top-level `effort` field produces a 400:
# "effort: Extra inputs are not permitted".
assert body["output_config"] == {"effort": "medium"}
assert "effort" not in body
# Extended-thinking contract: temperature=1, no top_p / top_k.
assert body["temperature"] == 1
assert "top_p" not in body
assert "top_k" not in body
def test_adaptive_thinking_maps_xhigh_to_max_on_claude_4_6(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _anthropic_sse([{"type": "message_stop"}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-sonnet-4-6",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
top_k = None,
enable_thinking = None,
reasoning_effort = "xhigh",
):
pass
await client.close()
_drive(run())
assert captured["body"]["output_config"] == {"effort": "max"}
def test_adaptive_thinking_keeps_max_on_claude_4_6(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _anthropic_sse([{"type": "message_stop"}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-6",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
top_k = None,
enable_thinking = None,
reasoning_effort = "max",
):
pass
await client.close()
_drive(run())
assert captured["body"]["output_config"] == {"effort": "max"}
def test_adaptive_thinking_keeps_xhigh_on_claude_4_7(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _anthropic_sse([{"type": "message_stop"}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
top_k = None,
enable_thinking = None,
reasoning_effort = "xhigh",
):
pass
await client.close()
_drive(run())
body = captured["body"]
assert body["output_config"] == {"effort": "xhigh"}
assert "effort" not in body
def test_manual_thinking_body_uses_budget_tokens_on_4_5(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _anthropic_sse([{"type": "message_stop"}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-5",
temperature = 0.7,
top_p = 0.95,
max_tokens = 1024,
top_k = None,
enable_thinking = None,
reasoning_effort = "high",
):
pass
await client.close()
_drive(run())
body = captured["body"]
assert body["thinking"] == {"type": "enabled", "budget_tokens": 4096}
# max_tokens must be strictly greater than budget_tokens; we shipped 1024
# and budget is 4096, so the wrapper should bump max_tokens.
assert body["max_tokens"] > body["thinking"]["budget_tokens"]
# Manual-thinking path does not use output_config / effort — those are
# the adaptive-mode controls (Claude 4.6 / 4.7).
assert "effort" not in body
assert "output_config" not in body
def test_thinking_delta_wrapped_in_think_tags(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
events = [
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": "", "signature": ""},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "First "},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "I plan."},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "signature_delta", "signature": "abc123"},
},
{"type": "content_block_stop", "index": 0},
{
"type": "content_block_start",
"index": 1,
"content_block": {"type": "text", "text": ""},
},
{
"type": "content_block_delta",
"index": 1,
"delta": {"type": "text_delta", "text": "Answer."},
},
{"type": "content_block_stop", "index": 1},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}},
{"type": "message_stop"},
]
return httpx.Response(
200,
content = _anthropic_sse(events),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
lines = await _collect(
client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-6",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
top_k = None,
enable_thinking = True,
reasoning_effort = None,
)
)
await client.close()
return lines
lines = _drive(run())
payloads = _payloads_from_lines(lines)
combined = "".join(
p["choices"][0]["delta"].get("content", "")
for p in payloads
if isinstance(p, dict) and p["choices"][0]["delta"]
)
# Reasoning text should be wrapped in <think>...</think>, followed by the
# answer text, and the stream should terminate with [DONE].
assert "<think>First I plan.</think>" in combined
assert combined.endswith("Answer.")
# signature_delta is intentionally dropped — no leaked signature text.
assert "abc123" not in combined
assert "[DONE]" in payloads
def test_thinking_only_turn_closes_tag_without_text_delta(monkeypatch):
"""display=omitted on Claude 4.7 emits a signature_delta and no text.
The <think> open is still triggered by the (synthetic) thinking_delta;
we want content_block_stop to close it cleanly so the tag never leaks
into the next chunk."""
def handler(request: httpx.Request) -> httpx.Response:
events = [
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": "", "signature": ""},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "internal"},
},
{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}},
{"type": "message_stop"},
]
return httpx.Response(
200,
content = _anthropic_sse(events),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
lines = await _collect(
client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4096,
top_k = None,
enable_thinking = True,
reasoning_effort = None,
)
)
await client.close()
return lines
payloads = _payloads_from_lines(_drive(run()))
combined = "".join(
p["choices"][0]["delta"].get("content", "")
for p in payloads
if isinstance(p, dict) and p["choices"][0]["delta"]
)
assert combined == "<think>internal</think>"

View file

@ -227,6 +227,60 @@ def test_desktop_refresh_preserves_desktop_marker():
assert payload["desktop"] is True
def test_consume_refresh_token_second_call_returns_none():
"""Single-use rotation rejects the same token on a second consume."""
seed_user()
from datetime import datetime, timedelta, timezone
raw = secrets.token_urlsafe(48)
expires = (datetime.now(timezone.utc) + timedelta(days = 30)).isoformat()
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
first = storage.consume_refresh_token(raw)
assert first == (storage.DEFAULT_ADMIN_USERNAME, False)
second = storage.consume_refresh_token(raw)
assert second is None
def test_consume_refresh_token_concurrent_only_one_succeeds(tmp_path, monkeypatch):
"""64-thread pile-up against one token; DELETE RETURNING permits one winner."""
seed_user()
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
raw = secrets.token_urlsafe(48)
expires = (datetime.now(timezone.utc) + timedelta(days = 30)).isoformat()
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
workers = 64
def attempt(_idx: int):
try:
return storage.consume_refresh_token(raw)
except sqlite3.OperationalError:
# "database is locked" under heavy contention; treat as losing the race.
return None
with ThreadPoolExecutor(max_workers = workers) as pool:
results = list(pool.map(attempt, range(workers)))
successes = [r for r in results if r is not None]
assert (
len(successes) == 1
), f"expected exactly one consumer to win, got {len(successes)}"
assert successes[0] == (storage.DEFAULT_ADMIN_USERNAME, False)
def test_consume_refresh_token_expired_returns_none():
seed_user()
from datetime import datetime, timedelta, timezone
raw = secrets.token_urlsafe(48)
expires = (datetime.now(timezone.utc) - timedelta(hours = 1)).isoformat()
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
assert storage.consume_refresh_token(raw) is None
def test_desktop_session_uses_real_admin_identity_for_api_keys():
seed_user(must_change_password = True)
raw = storage.create_desktop_secret()
@ -383,6 +437,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
inference_router = APIRouter(),
inference_studio_router = APIRouter(),
models_router = APIRouter(),
providers_router = APIRouter(),
training_history_router = APIRouter(),
training_router = APIRouter(),
)
@ -392,7 +447,21 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY", False)
body = asyncio.run(backend_main.health_check())
seed_user()
from auth.authentication import create_access_token
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
app = FastAPI()
app.add_api_route("/api/health", backend_main.health_check, methods = ["GET"])
client = TestClient(app)
response = client.get(
"/api/health",
headers = {"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
body = response.json()
assert body["desktop_protocol_version"] == 1
assert body["supports_desktop_auth"] is True

View file

@ -192,6 +192,7 @@ def _drive(
else:
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
matched = False
pin_fraction = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
@ -203,7 +204,7 @@ def _drive(
)
kv = inst._estimate_kv_cache_bytes(capped, cache_type_kv)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * 0.90:
if total_mib <= pool_mib * pin_fraction:
effective_ctx = capped
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
@ -211,6 +212,17 @@ def _drive(
break
if not matched:
effective_ctx = min(FALLBACK_CTX, effective_ctx)
# Mirror llama_cpp.py: re-check fit at FALLBACK_CTX.
if effective_ctx > 0:
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
kv = inst._estimate_kv_cache_bytes(effective_ctx, cache_type_kv)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * pin_fraction:
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
break
elif gpus:
gpu_indices, use_fit = inst._select_gpus(model_size, gpus)
if use_fit and not explicit_ctx:
@ -378,6 +390,52 @@ class TestFittableAutoPickRegressions:
assert plan["gpu_indices"] == [0]
# ---------------------------------------------------------------------------
# #5106 regression: 91-95% utilization must still pin GPU.
# ---------------------------------------------------------------------------
class TestTightFitPinsToGPU:
"""Models that fit at 91-95% of free VRAM must use the GPU."""
def test_rtx_4090_qwen_24gb_class(self):
# noahterbest's #5106 log: 20.8 GB model on 22805 MiB free
# GPU, ctx=4096 -> ~94% utilization, ~1.4 GiB headroom.
plan = _drive(
n_ctx = 0,
model_gib = 20.8,
gpus = [(0, 22_805)],
native_ctx = 131072,
kv_per_token_bytes = 25_000,
)
assert plan["use_fit"] is False
assert plan["gpu_indices"] == [0]
def test_explicit_ctx_at_94_pct_pins_to_gpu(self):
# Explicit-ctx branch must agree with auto-ctx on headroom.
plan = _drive(
n_ctx = 4096,
model_gib = 20.8,
gpus = [(0, 22_805)],
native_ctx = 131072,
kv_per_token_bytes = 25_000,
)
assert plan["use_fit"] is False
assert plan["gpu_indices"] == [0]
def test_genuine_overflow_still_uses_fit(self):
# Beyond 95% must still defer to --fit on.
plan = _drive(
n_ctx = 4096,
model_gib = 23,
gpus = [(0, 22_000)],
native_ctx = 131072,
kv_per_token_bytes = 25_000,
)
assert plan["use_fit"] is True
assert plan["gpu_indices"] is None
# ---------------------------------------------------------------------------
# Platform-agnostic input shape
# ---------------------------------------------------------------------------
@ -391,3 +449,81 @@ def test_identical_decision_across_platforms(platform_tag):
plan_a = _drive(n_ctx = 0, model_gib = 8, gpus = [(0, 24_000)])
plan_b = _drive(n_ctx = 0, model_gib = 8, gpus = [(0, 24_000)])
assert plan_a == plan_b, platform_tag
# ---------------------------------------------------------------------------
# _classify_gpu_offload: detect silent CPU fallback (#5106).
# ---------------------------------------------------------------------------
class TestClassifyGpuOffload:
def _backend(self, stdout_lines):
inst = LlamaCppBackend.__new__(LlamaCppBackend)
inst._stdout_lines = list(stdout_lines)
return inst
def test_cuda_buffer_present_returns_true(self):
inst = self._backend(
[
"load_tensors: offloaded 33/33 layers to GPU",
"load_tensors: CUDA0 model buffer size = 21000.0 MiB",
"load_tensors: CPU_Mapped model buffer size = 0.6 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
def test_cpu_only_buffer_returns_false(self):
# llama-server printed buffer lines but only CPU buffers --
# this is the silent CPU fallback symptom we want to catch.
inst = self._backend(
[
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB",
"load_tensors: CPU model buffer size = 0.6 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is False
def test_no_buffer_lines_returns_none(self):
# If we can't see buffer-allocation lines at all, don't guess.
inst = self._backend(
[
"INFO [main] starting server",
"load_tensors: file format = GGUF V3",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is None
def test_no_gpus_detected_returns_none(self):
# CPU-only systems are valid; suppress the warning entirely.
inst = self._backend(
[
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB",
]
)
assert inst._classify_gpu_offload(False, []) is None
def test_user_did_not_intend_gpu_returns_none(self):
# Studio called start_llama_server without expecting GPU use;
# don't warn.
inst = self._backend(
[
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB",
]
)
assert inst._classify_gpu_offload(False, [(0, 22805)]) is None
def test_rocm_buffer_marker_returns_true(self):
inst = self._backend(
[
"load_tensors: ROCm0 model buffer size = 21000.0 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
def test_metal_buffer_marker_returns_true(self):
inst = self._backend(
[
"load_tensors: Metal model buffer size = 8000.0 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True

View file

@ -0,0 +1,259 @@
# 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 the Windows pip-nvidia DLL dir resolver.
Studio installs torch with bundled CUDA wheels (nvidia-cuda-runtime-cu13,
nvidia-cublas-cu13, etc.) and the prebuilt llama-server.exe must find
those DLLs at runtime to load CUDA. Mirrors the Linux LD_LIBRARY_PATH
block. See unslothai/unsloth#5106.
"""
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)
# Stub heavy deps before importing the module under test.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
sys.modules.setdefault("structlog", _types.ModuleType("structlog"))
_httpx_stub = _types.ModuleType("httpx")
for _exc_name in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
):
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
class _FakeTimeout:
def __init__(self, *a, **kw):
pass
_httpx_stub.Timeout = _FakeTimeout
_httpx_stub.Client = type(
"Client",
(),
{
"__init__": lambda self, **kw: None,
"__enter__": lambda self: self,
"__exit__": lambda self, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
def _make_nvidia_layout(prefix: Path, pkgs_with_layout: dict[str, str]):
"""Build a fake <prefix>/Lib/site-packages/nvidia/<pkg>/{bin|Library/bin}
tree with a stub DLL inside each leaf so isdir() picks them up."""
nv = prefix / "Lib" / "site-packages" / "nvidia"
for pkg, layout in pkgs_with_layout.items():
if layout == "bin":
d = nv / pkg / "bin"
elif layout == "library_bin":
d = nv / pkg / "Library" / "bin"
else:
raise ValueError(layout)
d.mkdir(parents = True, exist_ok = True)
(d / "stub.dll").write_bytes(b"")
class TestWindowsPipNvidiaDllDirs:
def test_returns_empty_when_no_nvidia_wheels(self, tmp_path):
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_picks_up_bin_layout(self, tmp_path):
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "bin",
"cublas": "bin",
"cudnn": "bin",
},
)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 3
assert all(Path(p).is_dir() for p in result)
assert all(Path(p).name == "bin" for p in result)
names = {Path(p).parent.name for p in result}
assert names == {"cuda_runtime", "cublas", "cudnn"}
def test_picks_up_library_bin_layout(self, tmp_path):
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "library_bin",
"cublas": "library_bin",
},
)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 2
for p in result:
assert Path(p).is_dir()
assert Path(p).parent.name == "Library"
assert Path(p).parent.parent.name in {"cuda_runtime", "cublas"}
def test_mixed_layouts_all_resolved(self, tmp_path):
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "bin",
"cublas": "library_bin",
"cudnn": "bin",
"nvjitlink": "library_bin",
},
)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 4
def test_does_not_walk_outside_known_paths(self, tmp_path):
# Only nvidia/<pkg>/{bin,Library/bin} and torch/lib are picked
# up. Unrelated site-packages contents (numpy, scipy, ...) must
# be ignored.
site = tmp_path / "Lib" / "site-packages"
(site / "numpy").mkdir(parents = True)
(site / "scipy" / "linalg").mkdir(parents = True)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_picks_up_torch_lib(self, tmp_path):
# PyTorch's Windows CUDA wheel bundles cudart64_X.dll /
# cublas64_X.dll directly under Lib/site-packages/torch/lib/
# instead of as separate nvidia-* wheels. Without this, users
# on torch-bundled-CUDA installs still hit #5106.
torch_lib = tmp_path / "Lib" / "site-packages" / "torch" / "lib"
torch_lib.mkdir(parents = True)
(torch_lib / "cudart64_12.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 1
assert Path(result[0]) == torch_lib
def test_torch_lib_combined_with_nvidia_wheels(self, tmp_path):
# Both modular nvidia-* wheels and torch/lib are returned when
# present together.
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "bin",
"cublas": "bin",
},
)
torch_lib = tmp_path / "Lib" / "site-packages" / "torch" / "lib"
torch_lib.mkdir(parents = True)
(torch_lib / "cudart64_13.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 3
names = {Path(p).name for p in result}
assert names == {"bin", "lib"}
assert any(Path(p) == torch_lib for p in result)
def test_torch_lib_must_be_a_directory(self, tmp_path):
# If torch/lib exists as a file (broken install), it is
# ignored, not returned.
site = tmp_path / "Lib" / "site-packages" / "torch"
site.mkdir(parents = True)
(site / "lib").write_bytes(b"not a dir")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_skips_non_directories(self, tmp_path):
nv = tmp_path / "Lib" / "site-packages" / "nvidia"
(nv / "cuda_runtime").mkdir(parents = True)
# Create a regular file at the path where 'bin' would normally be a dir
(nv / "cuda_runtime" / "bin").write_bytes(b"not a dir")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_missing_prefix_does_not_raise(self):
# If sys.prefix points to a path that doesn't exist (unusual,
# but possible during test setup), the resolver must just
# return [] rather than raising.
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(
"/this/path/does/not/exist/anywhere"
)
assert result == []
def test_picks_up_cu13_bin_x86_64_layout(self, tmp_path):
# Current ``nvidia-cuda-runtime`` 13.x and ``nvidia-cublas``
# 13.x Windows wheels ship DLLs under
# ``nvidia/cu13/bin/x86_64/`` instead of ``nvidia/<pkg>/bin/``.
# Without this, users on the new CUDA 13 wheel generation hit
# the original #5106 failure mode.
dll_dir = (
tmp_path / "Lib" / "site-packages" / "nvidia" / "cu13" / "bin" / "x86_64"
)
dll_dir.mkdir(parents = True)
for name in ("cudart64_13.dll", "cublas64_13.dll", "cublasLt64_13.dll"):
(dll_dir / name).write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert str(dll_dir) in result, f"cu13 bin/x86_64 not in {result}"
def test_picks_up_bin_x64_layout(self, tmp_path):
# Some repackaged wheels use ``bin/x64`` (Windows-x64 convention)
# instead of ``bin/x86_64`` (NVIDIA-internal convention).
dll_dir = tmp_path / "Lib" / "site-packages" / "nvidia" / "cu13" / "bin" / "x64"
dll_dir.mkdir(parents = True)
(dll_dir / "cudart64_13.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert str(dll_dir) in result
def test_mixed_cu12_and_cu13_layouts(self, tmp_path):
# A venv could have both the modular cu12 wheels (legacy) and
# the unsuffixed cu13 wheel installed side by side. Both must
# be reachable.
site = tmp_path / "Lib" / "site-packages"
cu12_bin = site / "nvidia" / "cuda_runtime" / "bin"
cu13_arch = site / "nvidia" / "cu13" / "bin" / "x86_64"
cu12_bin.mkdir(parents = True)
cu13_arch.mkdir(parents = True)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
result_set = {Path(p) for p in result}
assert cu12_bin in result_set
assert cu13_arch in result_set
def test_glob_meta_in_prefix_is_safe(self, tmp_path):
# Windows usernames / install paths can contain ``[`` or ``]``.
# A glob-based resolver would interpret these as a character
# class and silently return [] even when DLL dirs exist. The
# iterdir-based implementation must work on such paths.
prefix = tmp_path / "studio_[gpu]_install"
dll_dir = prefix / "Lib" / "site-packages" / "nvidia" / "cuda_runtime" / "bin"
dll_dir.mkdir(parents = True)
(dll_dir / "cudart64_12.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(prefix))
assert str(dll_dir) in result, f"bracket-prefixed path returned empty: {result}"
def test_arch_subdir_listed_before_parent_bin(self, tmp_path):
# When both ``nvidia/<pkg>/bin/`` and
# ``nvidia/<pkg>/bin/x86_64/`` exist, the arch-specific subdir
# must be listed first so Windows DLL search picks up the
# cudart64_X.dll location even if the parent ``bin`` is empty.
site = tmp_path / "Lib" / "site-packages"
outer_bin = site / "nvidia" / "cu13" / "bin"
arch_bin = outer_bin / "x86_64"
arch_bin.mkdir(parents = True)
(arch_bin / "cudart64_13.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
# outer_bin exists as a directory (it contains arch_bin); the
# arch-specific subdir should come first in the list.
result_paths = [Path(p) for p in result]
assert arch_bin in result_paths
assert outer_bin in result_paths
assert result_paths.index(arch_bin) < result_paths.index(outer_bin)

View file

@ -0,0 +1,269 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for MaxBodyMiddleware, SecurityHeadersMiddleware, and the /api/health auth gate."""
import asyncio
import importlib.util
import json
import os
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
from fastapi.testclient import TestClient
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
@pytest.fixture(scope = "module")
def main_module():
import main as _main # noqa: F401
return _main
# =====================================================================
# MaxBodyMiddleware
# =====================================================================
def _make_protected_app(max_bytes: int, main_module):
app = FastAPI()
app.add_middleware(
main_module.MaxBodyMiddleware,
max_bytes = max_bytes,
protected_prefixes = ("/v1/chat/completions", "/api/train"),
)
@app.post("/v1/chat/completions")
async def chat(payload: dict):
return {"ok": True, "n": len(payload.get("text", ""))}
@app.post("/api/other")
async def other(payload: dict):
return {"ok": True, "unprotected": True}
@app.get("/api/train/status")
async def status_get():
return {"ok": True, "get": True}
return app
class TestMaxBodyMiddleware:
def test_small_protected_body_passes(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/v1/chat/completions", json = {"text": "x" * 100})
assert r.status_code == 200
assert r.json()["n"] == 100
def test_large_declared_content_length_rejected(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/v1/chat/completions", json = {"text": "x" * 5000})
assert r.status_code == 413
assert "too large" in r.json()["detail"].lower()
def test_unprotected_prefix_passes_large_body(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/api/other", json = {"text": "x" * 5000})
assert r.status_code == 200
assert r.json()["unprotected"] is True
def test_chunked_upload_over_cap_rejected(self, main_module):
# Regression: declared-Content-Length-only check could be bypassed
# by chunked transfer-encoding.
app = _make_protected_app(1024, main_module)
c = TestClient(app)
def gen():
yield b'{"text":"'
yield b"x" * 800
yield b'"}'
yield b"\n" + b"y" * 500
r = c.post(
"/v1/chat/completions",
content = gen(),
headers = {"content-type": "application/json"},
)
assert r.status_code == 413
assert "too large" in r.json()["detail"].lower()
def test_chunked_upload_under_cap_passes(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
def gen():
yield b'{"text":"'
yield b"x" * 50
yield b'"}'
r = c.post(
"/v1/chat/completions",
content = gen(),
headers = {"content-type": "application/json"},
)
assert r.status_code == 200
assert r.json()["n"] == 50
def test_get_not_subject_to_cap(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.get("/api/train/status")
assert r.status_code == 200
# =====================================================================
# SecurityHeadersMiddleware / CSP
# =====================================================================
def _make_csp_app(main_module, attach_nonce: str | None = None):
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get("/plain")
async def plain():
return {"ok": True}
@app.get("/with-nonce")
async def with_nonce():
headers = {}
if attach_nonce:
headers[main_module._CSP_SCRIPT_NONCE_HEADER] = attach_nonce
return Response(
content = b"<html></html>",
media_type = "text/html",
headers = headers,
)
return app
class TestSecurityHeadersMiddleware:
def test_csp_has_no_unsafe_inline_for_script_src(self, main_module):
app = _make_csp_app(main_module)
c = TestClient(app)
r = c.get("/plain")
assert r.status_code == 200
csp = r.headers["content-security-policy"]
# Parse per-directive so style-src unsafe-inline does not false-match.
directives = {
chunk.strip().split(" ", 1)[0]: chunk.strip()
for chunk in csp.split(";")
if chunk.strip()
}
assert "script-src" in directives
assert "'unsafe-inline'" not in directives["script-src"]
# style-src keeps unsafe-inline for Vite-injected styles.
assert "'unsafe-inline'" in directives["style-src"]
def test_default_security_headers_present(self, main_module):
app = _make_csp_app(main_module)
c = TestClient(app)
r = c.get("/plain")
assert r.headers["x-frame-options"] == "DENY"
assert r.headers["x-content-type-options"] == "nosniff"
assert r.headers["referrer-policy"] == "no-referrer"
assert "camera=()" in r.headers["permissions-policy"]
assert r.headers["server"] == "unsloth-studio"
def test_internal_nonce_header_is_spliced_into_csp_and_stripped(self, main_module):
nonce = "test-nonce-abc"
app = _make_csp_app(main_module, attach_nonce = nonce)
c = TestClient(app)
r = c.get("/with-nonce")
csp = r.headers["content-security-policy"]
assert f"'nonce-{nonce}'" in csp
# Internal handoff header must not leak to clients.
assert main_module._CSP_SCRIPT_NONCE_HEADER not in {
k.lower() for k in r.headers.keys()
}
def test_build_csp_helper_shape(self, main_module):
plain = main_module._build_csp()
assert "script-src 'self';" in plain
assert "'unsafe-inline'" not in plain.split("script-src", 1)[1].split(";", 1)[0]
nonced = main_module._build_csp("XYZ")
assert "script-src 'self' 'nonce-XYZ';" in nonced
# =====================================================================
# /api/health auth gate
# =====================================================================
@pytest.fixture
def health_app(tmp_path, monkeypatch):
"""Mount /api/health on a fresh app against an isolated auth db."""
from auth import storage
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
monkeypatch.setattr(storage, "_bootstrap_password", None)
import main as _main
app = FastAPI()
app.add_api_route("/api/health", _main.health_check, methods = ["GET"])
import secrets as _secrets
storage.create_initial_user(
username = storage.DEFAULT_ADMIN_USERNAME,
password = "human-password-123",
jwt_secret = _secrets.token_urlsafe(64),
must_change_password = False,
)
return app
class TestHealthAuthGate:
def test_no_auth_returns_minimal_payload(self, health_app):
c = TestClient(health_app)
r = c.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert "timestamp" in body
for forbidden in ("version", "device_type", "studio_root_id"):
assert forbidden not in body
def test_invalid_bearer_returns_minimal_payload(self, health_app):
# Regression: calling the async dep without await made any Bearer header pass.
c = TestClient(health_app)
r = c.get(
"/api/health",
headers = {"Authorization": "Bearer not-a-real-token"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
for forbidden in ("version", "device_type", "studio_root_id"):
assert forbidden not in body
def test_valid_bearer_returns_full_payload(self, health_app):
from auth import storage
from auth.authentication import create_access_token
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
c = TestClient(health_app)
r = c.get(
"/api/health",
headers = {"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert "version" in body
assert "device_type" in body
assert "studio_root_id" in body

View file

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

View file

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

View file

@ -0,0 +1,432 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Unit tests for the OpenAI `/v1/responses` translation in external_provider.
Covers:
- Request body shape: system messages collapse into `instructions`, user/
assistant messages go into `input`, sampling knobs Responses does not
support (presence_penalty, top_k) are not forwarded.
- SSE translation: `response.output_text.delta` events become OpenAI Chat
Completions chunks, `response.completed` emits a `finish_reason: stop`
chunk, the stream terminates with `data: [DONE]`.
- Image parts in user content are rewritten from Chat Completions
`{type: image_url, image_url: {url}}` into Responses
`{type: input_image, image_url: <url>}`.
"""
import asyncio
import json
import httpx
from core.inference import external_provider as ep_mod
from core.inference.external_provider import ExternalProviderClient
def _drive(coro):
return asyncio.new_event_loop().run_until_complete(coro)
async def _collect(agen):
out = []
async for line in agen:
out.append(line)
return out
def _mock_http_client(monkeypatch, handler):
transport = httpx.MockTransport(handler)
monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport))
def _make_client() -> ExternalProviderClient:
return ExternalProviderClient(
provider_type = "openai",
base_url = "https://api.openai.com/v1",
api_key = "sk-test",
)
def _responses_sse(events: list[dict]) -> bytes:
"""Serialize a list of Responses-API event dicts as an SSE byte stream."""
chunks: list[str] = []
for event in events:
chunks.append(f"event: {event['type']}")
chunks.append(f"data: {json.dumps(event)}")
chunks.append("")
chunks.append("data: [DONE]")
chunks.append("")
return ("\n".join(chunks) + "\n").encode("utf-8")
def test_responses_request_body_uses_input_and_instructions(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _responses_sse([{"type": "response.completed", "response": {}}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_openai_responses(
messages = [
{"role": "system", "content": "You are concise."},
{"role": "user", "content": "Hi"},
],
model = "gpt-5.5",
temperature = 0.5,
top_p = 0.9,
max_tokens = 512,
enable_thinking = None,
reasoning_effort = None,
):
pass
await client.close()
_drive(run())
assert captured["url"] == "https://api.openai.com/v1/responses"
body = captured["body"]
assert body["model"] == "gpt-5.5"
assert body["instructions"] == "You are concise."
assert body["input"] == [{"role": "user", "content": "Hi"}]
assert body["max_output_tokens"] == 512
assert body["stream"] is True
# Responses API on reasoning-class models (gpt-5.x / o3 / gpt-4.5 — the
# only OpenAI ids the registry allowlist exposes) rejects these as
# `Unsupported parameter`. Make sure we never silently forward them.
assert "temperature" not in body
assert "top_p" not in body
assert "presence_penalty" not in body
assert "frequency_penalty" not in body
assert "top_k" not in body
assert "messages" not in body
def test_responses_translates_image_parts(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _responses_sse([{"type": "response.completed", "response": {}}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_openai_responses(
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAA"},
},
],
}
],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = None,
enable_thinking = None,
reasoning_effort = None,
):
pass
await client.close()
_drive(run())
parts = captured["body"]["input"][0]["content"]
assert parts[0] == {"type": "input_text", "text": "What is this?"}
assert parts[1] == {
"type": "input_image",
"image_url": "data:image/png;base64,AAA",
}
# No max_output_tokens key when caller passes max_tokens=None.
assert "max_output_tokens" not in captured["body"]
def test_responses_sse_translates_to_chat_completions_chunks(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
events = [
{"type": "response.created"},
{"type": "response.output_text.delta", "delta": "Hello"},
{"type": "response.output_text.delta", "delta": ", world"},
{"type": "response.completed", "response": {}},
]
return httpx.Response(
200,
content = _responses_sse(events),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
lines = await _collect(
client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = None,
enable_thinking = None,
reasoning_effort = None,
)
)
await client.close()
return lines
lines = _drive(run())
# Drop empty / non-data lines for assertion clarity.
data_lines = [line for line in lines if line.startswith("data:")]
payloads = []
for line in data_lines:
raw = line[len("data:") :].strip()
if raw == "[DONE]":
payloads.append("[DONE]")
else:
payloads.append(json.loads(raw))
# Two text deltas, one terminal chunk, then [DONE].
assert payloads[0]["choices"][0]["delta"]["content"] == "Hello"
assert payloads[0]["choices"][0]["finish_reason"] is None
assert payloads[1]["choices"][0]["delta"]["content"] == ", world"
assert payloads[2]["choices"][0]["delta"] == {}
assert payloads[2]["choices"][0]["finish_reason"] == "stop"
assert payloads[-1] == "[DONE]"
def test_responses_response_incomplete_maps_to_length_finish_reason(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
events = [
{"type": "response.output_text.delta", "delta": "partial"},
{"type": "response.incomplete", "response": {}},
]
return httpx.Response(
200,
content = _responses_sse(events),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
lines = await _collect(
client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = 4,
enable_thinking = None,
reasoning_effort = None,
)
)
await client.close()
return lines
lines = _drive(run())
finish_reasons = [
json.loads(line[len("data:") :].strip())["choices"][0]["finish_reason"]
for line in lines
if line.startswith("data:")
and line[len("data:") :].strip() not in ("", "[DONE]")
]
assert "length" in finish_reasons
def test_responses_reasoning_effort_included_when_requested(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _responses_sse([{"type": "response.completed", "response": {}}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = None,
enable_thinking = None,
reasoning_effort = "high",
):
pass
await client.close()
_drive(run())
assert captured["body"]["reasoning"] == {"effort": "high", "summary": "auto"}
def test_responses_reasoning_effort_none_omits_summary(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _responses_sse([{"type": "response.completed", "response": {}}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = None,
enable_thinking = None,
reasoning_effort = "none",
):
pass
await client.close()
_drive(run())
assert captured["body"]["reasoning"] == {"effort": "none"}
def test_responses_reasoning_effort_xhigh_passthrough(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _responses_sse([{"type": "response.completed", "response": {}}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = None,
enable_thinking = None,
reasoning_effort = "xhigh",
):
pass
await client.close()
_drive(run())
assert captured["body"]["reasoning"] == {"effort": "xhigh", "summary": "auto"}
def test_responses_enable_thinking_false_maps_to_reasoning_none(monkeypatch):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = _responses_sse([{"type": "response.completed", "response": {}}]),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
async for _ in client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = None,
enable_thinking = False,
reasoning_effort = None,
):
pass
await client.close()
_drive(run())
assert captured["body"]["reasoning"] == {"effort": "none"}
def test_responses_reasoning_summary_wrapped_in_think_tags(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
events = [
{
"type": "response.output_item.done",
"item": {
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "plan"}],
},
},
{"type": "response.output_text.delta", "delta": "answer"},
{"type": "response.completed", "response": {}},
]
return httpx.Response(
200,
content = _responses_sse(events),
headers = {"content-type": "text/event-stream"},
)
_mock_http_client(monkeypatch, handler)
async def run():
client = _make_client()
lines = await _collect(
client._stream_openai_responses(
messages = [{"role": "user", "content": "hi"}],
model = "gpt-5.5",
temperature = 0.7,
top_p = 0.95,
max_tokens = None,
enable_thinking = None,
reasoning_effort = None,
)
)
await client.close()
return lines
lines = _drive(run())
data_lines = [
line[len("data:") :].strip()
for line in lines
if line.startswith("data:")
and line[len("data:") :].strip() not in ("", "[DONE]")
]
payloads = [json.loads(raw) for raw in data_lines]
combined = "".join(
payload["choices"][0]["delta"].get("content", "")
for payload in payloads
if payload["choices"][0]["delta"]
)
assert "<think>plan</think>answer" in combined

View file

@ -125,22 +125,21 @@ class TestChatMessageToolRoles:
)
assert msg.content is None
def test_tool_role_missing_tool_call_id_rejected(self):
# Per OpenAI spec, role="tool" messages must carry tool_call_id so
# upstream backends can associate the result with its prior call.
# Pin the boundary-level rejection so a malformed tool-result
# message never reaches the passthrough path.
with pytest.raises(ValidationError) as exc_info:
ChatMessage(role = "tool", content = '{"temperature": 72}')
assert "tool_call_id" in str(exc_info.value)
def test_tool_role_missing_tool_call_id_synthesised(self):
# Frontend drops the id on second-round POST; validator synthesises one.
msg = ChatMessage(role = "tool", content = '{"temperature": 72}')
assert msg.tool_call_id is not None
assert msg.tool_call_id.startswith("call_")
assert len(msg.tool_call_id) >= len("call_") + 8
def test_tool_role_empty_tool_call_id_rejected(self):
with pytest.raises(ValidationError):
ChatMessage(
role = "tool",
tool_call_id = "",
content = '{"temperature": 72}',
)
def test_tool_role_empty_tool_call_id_synthesised(self):
msg = ChatMessage(
role = "tool",
tool_call_id = "",
content = '{"temperature": 72}',
)
assert msg.tool_call_id is not None
assert msg.tool_call_id.startswith("call_")
# ── Role-aware content requirements ────────────────────────────
@ -162,10 +161,19 @@ class TestChatMessageToolRoles:
ChatMessage(role = "tool", tool_call_id = "call_1", content = "")
assert "content" in str(exc_info.value)
def test_assistant_without_content_or_tool_calls_rejected(self):
with pytest.raises(ValidationError) as exc_info:
ChatMessage(role = "assistant")
assert "content" in str(exc_info.value) or "tool_calls" in str(exc_info.value)
def test_assistant_without_content_or_tool_calls_tolerated(self):
# Stop-button leaves an empty assistant turn; tolerate so replay round-trips.
msg = ChatMessage(role = "assistant")
assert msg.content is None
assert msg.tool_calls is None
def test_assistant_empty_string_content_normalised_to_none(self):
msg = ChatMessage(role = "assistant", content = "")
assert msg.content is None
def test_assistant_empty_list_content_normalised_to_none(self):
msg = ChatMessage(role = "assistant", content = [])
assert msg.content is None
# ── Role-constrained tool-call metadata ────────────────────────
@ -472,3 +480,91 @@ class TestFriendlyErrorHttpx:
assert (
_friendly_error(RuntimeError("unrelated")) == "An internal error occurred"
)
from routes.inference import ( # noqa: E402
_drop_empty_assistant_sentinels,
_openai_messages_for_passthrough,
)
class TestDropEmptyAssistantSentinels:
def test_drops_empty_assistant_between_real_turns(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": ""},
{"role": "user", "content": "again"},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == [
{"role": "user", "content": "hi"},
{"role": "user", "content": "again"},
]
def test_drops_assistant_with_no_content_key(self):
# exclude_none=True strips the content key entirely; filter must catch this.
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant"},
{"role": "user", "content": "ok"},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == [
{"role": "user", "content": "hi"},
{"role": "user", "content": "ok"},
]
def test_preserves_assistant_with_text(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello back"},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == msgs
def test_preserves_assistant_with_tool_calls_only(self):
msgs = [
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
},
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": '{"t": 72}',
},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == msgs
def test_preserves_user_and_system_with_empty_content(self):
# Filter scoped to role="assistant" only.
msgs = [
{"role": "system", "content": ""},
{"role": "user", "content": ""},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == msgs
def test_openai_messages_for_passthrough_drops_sentinel(self):
"""End-to-end: Stop-sentinel must not reach the wire."""
req = ChatCompletionRequest(
model = "default",
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(role = "assistant", content = ""),
ChatMessage(role = "user", content = "again"),
],
)
out = _openai_messages_for_passthrough(req)
roles = [m["role"] for m in out]
assert roles == ["user", "user"]
for m in out:
assert m.get("content"), m

View file

@ -0,0 +1,609 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Integration tests for the external providers API.
Requires a running Unsloth Studio server. Configure via environment variables:
export STUDIO_TEST_URL="http://localhost:8888" # default
export STUDIO_TEST_USER="unsloth" # default
export STUDIO_TEST_PASSWORD="..." # required — see .bootstrap_password
# Provider API keys — any left unset will have their tests automatically skipped
export OPENAI_API_KEY="sk-..."
export MISTRAL_API_KEY="..."
export GOOGLE_API_KEY="..."
export TOGETHER_API_KEY="..."
export FIREWORKS_API_KEY="..."
export PERPLEXITY_API_KEY="..."
Run:
cd studio/backend
pytest tests/test_providers_api.py -v -s
"""
import base64
import json
import os
import pytest
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
# ── Configuration ─────────────────────────────────────────────────
BASE_URL = os.getenv("STUDIO_TEST_URL", "http://localhost:8000")
USERNAME = os.getenv("STUDIO_TEST_USER", "unsloth")
PASSWORD = os.getenv("STUDIO_TEST_PASSWORD", "")
# These tests require a live Studio server reachable at BASE_URL with a known
# bootstrap password. Skip the whole module when that environment is missing
# (e.g. on CI runners) so pytest discovery does not error out.
pytestmark = pytest.mark.skipif(
not PASSWORD,
reason = "Integration test requires a running Studio server; set STUDIO_TEST_PASSWORD to enable.",
)
# Map provider_type → (env var name, model to use for inference test)
_PROVIDER_CONFIGS: dict[str, tuple[str, str]] = {
"openai": ("OPENAI_API_KEY", "gpt-4o-mini"),
"mistral": ("MISTRAL_API_KEY", "mistral-small-2506"),
"gemini": ("GEMINI_API_KEY", "gemini-3-flash-preview"),
"openrouter": ("OPENROUTER_API_KEY", "openai/gpt-4o-mini"),
"anthropic": ("ANTHROPIC_API_KEY", "claude-haiku-4-5"),
"deepseek": ("DEEPSEEK_API_KEY", "deepseek-chat"),
"huggingface": ("HUGGINGFACE_API_KEY", "meta-llama/Llama-3.3-70B-Instruct"),
"kimi": ("MOONSHOT_API_KEY", "moonshot-v1-8k"),
"qwen": ("DASHSCOPE_API_KEY", "qwen-turbo"),
}
PROVIDER_KEYS: dict[str, str] = {
ptype: os.getenv(env_var, "") for ptype, (env_var, _) in _PROVIDER_CONFIGS.items()
}
EXPECTED_PROVIDER_TYPES = set(_PROVIDER_CONFIGS.keys())
# ── Helpers ────────────────────────────────────────────────────────
def _url(path: str) -> str:
return f"{BASE_URL}/{path.lstrip('/')}"
def _parse_sse_stream(response: requests.Response) -> tuple[str, bool]:
"""
Read a streaming SSE response and return (assembled_text, saw_done).
Each chunk is a JSON object with choices[0].delta.content.
The stream ends with `data: [DONE]`.
"""
reply_parts: list[str] = []
saw_done = False
for raw_line in response.iter_lines():
if isinstance(raw_line, bytes):
raw_line = raw_line.decode("utf-8")
if not raw_line.startswith("data:"):
continue
data = raw_line[len("data:") :].strip()
if data == "[DONE]":
saw_done = True
break
try:
chunk = json.loads(data)
# Handle both error payloads and normal chunks
if "error" in chunk:
raise RuntimeError(f"Provider error in stream: {chunk['error']}")
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content") or ""
if content:
reply_parts.append(content)
except (json.JSONDecodeError, IndexError, KeyError):
pass # skip malformed lines
return "".join(reply_parts), saw_done
# ── Session-scoped fixtures ────────────────────────────────────────
@pytest.fixture(scope = "session")
def auth_headers() -> dict[str, str]:
"""
Log in once per session and return auth headers.
On a fresh Studio install the bootstrap password triggers a forced password
change (must_change_password=True). Any subsequent API call using that token
returns 403 "Password change required". This fixture detects that state,
automatically completes the change-password flow, and re-logs in so all other
tests get a fully usable token.
The new password used during auto-change is:
STUDIO_TEST_NEW_PASSWORD (env var, optional)
or PASSWORD + "-test" (derived default)
On the second run, set STUDIO_TEST_PASSWORD to the new password.
"""
assert PASSWORD, (
"STUDIO_TEST_PASSWORD is not set.\n"
"Run: export STUDIO_TEST_PASSWORD=$(cat studio/backend/.bootstrap_password)"
)
resp = requests.post(
_url("/api/auth/login"),
json = {"username": USERNAME, "password": PASSWORD},
timeout = 10,
)
assert resp.status_code == 200, f"Login failed ({resp.status_code}): {resp.text}"
body = resp.json()
token = body["access_token"]
assert token, "access_token is empty"
if body.get("must_change_password"):
# Bootstrap token is restricted — only /api/auth/change-password works with it.
# Auto-complete the forced change so the rest of the tests get a full token.
new_password = os.getenv("STUDIO_TEST_NEW_PASSWORD") or f"{PASSWORD}-test"
change_resp = requests.post(
_url("/api/auth/change-password"),
headers = {"Authorization": f"Bearer {token}"},
json = {"current_password": PASSWORD, "new_password": new_password},
timeout = 10,
)
assert (
change_resp.status_code == 200
), f"Auto password-change failed ({change_resp.status_code}): {change_resp.text}"
token = change_resp.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture(scope = "session")
def public_key_pem(auth_headers: dict[str, str]) -> str:
"""Fetch RSA public key PEM once per session."""
resp = requests.get(
_url("/api/providers/public-key"),
headers = auth_headers,
timeout = 10,
)
assert resp.status_code == 200, f"Public key fetch failed: {resp.text}"
pem = resp.json().get("public_key", "")
assert pem.startswith("-----BEGIN PUBLIC KEY-----"), "Not a valid PEM public key"
return pem
@pytest.fixture(scope = "session")
def vision_image_data_url() -> str:
"""
Download the sloth image once per session and return it as a base64 data URI.
Using a data URI instead of a remote URL ensures every provider receives
the image inline Gemini's OpenAI-compatible layer does not fetch external
HTTP URLs, so raw image_url links silently produce empty replies for Gemini.
"""
resp = requests.get(_VISION_IMAGE_URL, timeout = 30)
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "image/jpeg").split(";")[0].strip()
b64 = base64.b64encode(resp.content).decode("utf-8")
return f"data:{content_type};base64,{b64}"
@pytest.fixture(scope = "session")
def encrypt_key(public_key_pem: str):
"""
Return a callable encrypt_key(plaintext: str) -> str (base64 RSA-OAEP ciphertext).
Uses the backend's RSA public key — mirrors what the frontend does.
"""
# Decode PEM → load RSA public key
pem_bytes = public_key_pem.encode("utf-8")
rsa_pub = serialization.load_pem_public_key(pem_bytes)
def _encrypt(plaintext: str) -> str:
ciphertext = rsa_pub.encrypt(
plaintext.encode("utf-8"),
padding.OAEP(
mgf = padding.MGF1(algorithm = hashes.SHA256()),
algorithm = hashes.SHA256(),
label = None,
),
)
return base64.b64encode(ciphertext).decode("utf-8")
return _encrypt
# ── TestAuth ────────────────────────────────────────────────────────
class TestAuth:
def test_login_returns_token(self):
"""POST /api/auth/login returns a non-empty access_token."""
assert PASSWORD, "STUDIO_TEST_PASSWORD not set"
resp = requests.post(
_url("/api/auth/login"),
json = {"username": USERNAME, "password": PASSWORD},
timeout = 10,
)
assert (
resp.status_code == 200
), f"Login failed ({resp.status_code}): {resp.text}"
body = resp.json()
assert body.get("access_token"), "access_token is missing or empty"
assert body.get("token_type") == "bearer"
# ── TestPublicKey ────────────────────────────────────────────────────
class TestPublicKey:
def test_public_key_is_valid_pem(
self, auth_headers: dict[str, str], public_key_pem: str
):
"""GET /api/providers/public-key returns an importable RSA PEM key."""
pem_bytes = public_key_pem.encode("utf-8")
key = serialization.load_pem_public_key(pem_bytes)
key_size = key.key_size # type: ignore[attr-defined]
assert key_size >= 2048, f"Key size too small: {key_size}"
print(f"\n RSA-{key_size} public key OK")
# ── TestRegistry ────────────────────────────────────────────────────
class TestRegistry:
def test_registry_returns_all_providers(self, auth_headers: dict[str, str]):
"""GET /api/providers/registry returns all supported providers."""
resp = requests.get(
_url("/api/providers/registry"),
headers = auth_headers,
timeout = 10,
)
assert resp.status_code == 200, f"Registry failed: {resp.text}"
providers = resp.json()
assert (
len(providers) == 9
), f"Expected 9 providers, got {len(providers)}: {providers}"
print(f"\n {'Provider':<12} {'Base URL'}")
print(f" {'-'*12} {'-'*45}")
for p in providers:
print(f" {p['provider_type']:<12} {p['base_url']}")
def test_registry_has_expected_types(self, auth_headers: dict[str, str]):
"""All expected provider_type values are present in the registry."""
resp = requests.get(
_url("/api/providers/registry"),
headers = auth_headers,
timeout = 10,
)
assert resp.status_code == 200
returned_types = {p["provider_type"] for p in resp.json()}
missing = EXPECTED_PROVIDER_TYPES - returned_types
assert not missing, f"Missing provider types: {missing}"
def test_registry_entries_have_required_fields(self, auth_headers: dict[str, str]):
"""Each registry entry has provider_type, display_name, base_url, default_models."""
resp = requests.get(
_url("/api/providers/registry"), headers = auth_headers, timeout = 10
)
assert resp.status_code == 200
for entry in resp.json():
for field in (
"provider_type",
"display_name",
"base_url",
"default_models",
"model_list_mode",
):
assert field in entry, f"Missing field '{field}' in entry: {entry}"
assert entry["model_list_mode"] in ("remote", "curated")
assert isinstance(entry["default_models"], list)
assert len(entry["default_models"]) > 0
# ── TestProviderCRUD ────────────────────────────────────────────────
class TestProviderCRUD:
"""
These tests run sequentially within the class and share state via class variables.
They create, read, update, and delete a single test provider config.
"""
_created_id: str = ""
def test_create_provider(self, auth_headers: dict[str, str]):
"""POST /api/providers/ creates a provider config and returns 201."""
resp = requests.post(
_url("/api/providers/"),
headers = auth_headers,
json = {"provider_type": "openai", "display_name": "Test OpenAI (pytest)"},
timeout = 10,
)
assert (
resp.status_code == 201
), f"Create failed ({resp.status_code}): {resp.text}"
body = resp.json()
assert body.get("id"), "No id in response"
assert body["provider_type"] == "openai"
assert body["display_name"] == "Test OpenAI (pytest)"
assert body["is_enabled"] is True
TestProviderCRUD._created_id = body["id"]
print(f"\n created id={body['id']}")
def test_list_includes_created(self, auth_headers: dict[str, str]):
"""GET /api/providers/ includes the newly created config."""
assert (
TestProviderCRUD._created_id
), "No created_id (run test_create_provider first)"
resp = requests.get(_url("/api/providers/"), headers = auth_headers, timeout = 10)
assert resp.status_code == 200
ids = [p["id"] for p in resp.json()]
assert (
TestProviderCRUD._created_id in ids
), f"Created id {TestProviderCRUD._created_id!r} not found in list: {ids}"
print(f"\n found id={TestProviderCRUD._created_id} in list of {len(ids)}")
def test_update_display_name(self, auth_headers: dict[str, str]):
"""PUT /api/providers/{id} updates the display_name."""
assert TestProviderCRUD._created_id, "No created_id"
new_name = "Test OpenAI (pytest updated)"
resp = requests.put(
_url(f"/api/providers/{TestProviderCRUD._created_id}"),
headers = auth_headers,
json = {"display_name": new_name},
timeout = 10,
)
assert (
resp.status_code == 200
), f"Update failed ({resp.status_code}): {resp.text}"
assert resp.json()["display_name"] == new_name
print(f"\n updated display_name to '{new_name}'")
def test_delete_provider(self, auth_headers: dict[str, str]):
"""DELETE /api/providers/{id} removes the config (204) and it's gone from list."""
assert TestProviderCRUD._created_id, "No created_id"
resp = requests.delete(
_url(f"/api/providers/{TestProviderCRUD._created_id}"),
headers = auth_headers,
timeout = 10,
)
assert (
resp.status_code == 204
), f"Delete failed ({resp.status_code}): {resp.text}"
# Confirm gone from list
list_resp = requests.get(
_url("/api/providers/"), headers = auth_headers, timeout = 10
)
ids = [p["id"] for p in list_resp.json()]
assert TestProviderCRUD._created_id not in ids, "Deleted provider still in list"
print(f"\n deleted id={TestProviderCRUD._created_id} confirmed gone")
# ── TestProviderInference ────────────────────────────────────────────
# Build parametrize list: (provider_type, model, api_key) for configured providers only
_INFERENCE_PARAMS = [
pytest.param(
ptype,
model,
PROVIDER_KEYS.get(ptype, ""),
id = ptype,
marks = pytest.mark.skipif(
not PROVIDER_KEYS.get(ptype, ""),
reason = f"no {env_var} set",
),
)
for ptype, (env_var, model) in _PROVIDER_CONFIGS.items()
]
class TestProviderInference:
"""
Live inference tests one parametrized set per provider.
Each test is automatically skipped when the provider's API key env var is not set.
"""
@pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS)
def test_connection(
self,
auth_headers: dict[str, str],
encrypt_key,
provider_type: str,
model: str,
api_key: str,
):
"""POST /api/providers/test → success: true."""
encrypted = encrypt_key(api_key)
resp = requests.post(
_url("/api/providers/test"),
headers = auth_headers,
json = {"provider_type": provider_type, "encrypted_api_key": encrypted},
timeout = 30,
)
assert (
resp.status_code == 200
), f"Request failed ({resp.status_code}): {resp.text}"
body = resp.json()
assert (
body["success"] is True
), f"Connection test failed for {provider_type}: {body.get('message')}"
print(f"\n [{provider_type}] connection OK — {body['message']}")
@pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS)
def test_list_models(
self,
auth_headers: dict[str, str],
encrypt_key,
provider_type: str,
model: str,
api_key: str,
):
"""POST /api/providers/models → non-empty list, print first 3."""
encrypted = encrypt_key(api_key)
resp = requests.post(
_url("/api/providers/models"),
headers = auth_headers,
json = {"provider_type": provider_type, "encrypted_api_key": encrypted},
timeout = 30,
)
assert (
resp.status_code == 200
), f"Request failed ({resp.status_code}): {resp.text}"
models = resp.json()
assert isinstance(models, list), f"Expected list, got {type(models)}"
assert len(models) > 0, f"No models returned for {provider_type}"
preview = [m["id"] for m in models[:3]]
print(f"\n [{provider_type}] {len(models)} models — first 3: {preview}")
@pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS)
def test_chat_inference(
self,
auth_headers: dict[str, str],
encrypt_key,
provider_type: str,
model: str,
api_key: str,
):
"""POST /v1/chat/completions with provider fields → streamed reply."""
encrypted = encrypt_key(api_key)
payload = {
"messages": [{"role": "user", "content": "Say hello in one sentence."}],
"stream": True,
"temperature": 0.7,
"max_tokens": 64,
"provider_type": provider_type,
"external_model": model,
"encrypted_api_key": encrypted,
}
with requests.post(
_url("/v1/chat/completions"),
headers = {**auth_headers, "Content-Type": "application/json"},
json = payload,
stream = True,
timeout = 60,
) as resp:
assert (
resp.status_code == 200
), f"Chat completions failed ({resp.status_code}): {resp.text[:500]}"
reply, saw_done = _parse_sse_stream(resp)
assert reply.strip(), f"Empty reply from {provider_type}/{model}"
assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}"
print(f'\n [{provider_type}/{model}] reply: "{reply.strip()}"')
# ── TestVisionInference ─────────────────────────────────────────────
# Sloth photo — used to test vision routing across providers
_VISION_IMAGE_URL = (
"https://www.travelexcellence.com/images/where-to-see-sloths-in-costa-rica.jpg"
)
_VISION_PARAMS = [
pytest.param(
ptype,
model,
PROVIDER_KEYS.get(ptype, ""),
id = ptype,
marks = pytest.mark.skipif(
not PROVIDER_KEYS.get(ptype, ""),
reason = f"no key for {ptype}",
),
)
for ptype, (_, model) in _PROVIDER_CONFIGS.items()
if ptype in {"openai", "mistral", "gemini", "anthropic", "openrouter"}
]
class TestVisionInference:
"""
Send a 1×1 white PNG alongside a text question to each vision-capable provider.
Verifies that image content parts survive the proxy and the provider replies.
"""
@pytest.mark.parametrize("provider_type,model,api_key", _VISION_PARAMS)
def test_vision_chat_inference(
self,
auth_headers: dict[str, str],
encrypt_key,
vision_image_data_url: str,
provider_type: str,
model: str,
api_key: str,
):
"""Image URL + text message → non-empty streamed reply."""
encrypted = encrypt_key(api_key)
payload = {
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Which animal is in this image? Reply in one word.",
},
{
"type": "image_url",
"image_url": {"url": vision_image_data_url},
},
],
}
],
"stream": True,
"max_tokens": 215,
"provider_type": provider_type,
"external_model": model,
"encrypted_api_key": encrypted,
}
with requests.post(
_url("/v1/chat/completions"),
headers = {**auth_headers, "Content-Type": "application/json"},
json = payload,
stream = True,
timeout = 60,
) as resp:
assert (
resp.status_code == 200
), f"Vision request failed ({resp.status_code}): {resp.text[:300]}"
reply, saw_done = _parse_sse_stream(resp)
assert reply.strip(), f"Empty reply from {provider_type}/{model}"
assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}"
print(f"\n [{provider_type}/{model}] vision reply: {reply.strip()!r}")
# ── TestLocalInferenceUnaffected ────────────────────────────────────
class TestLocalInferenceUnaffected:
def test_chat_without_provider(self, auth_headers: dict[str, str]):
"""
POST /v1/chat/completions without provider fields must not return 422 or 500.
200 = a local model is loaded and responded.
503 = no model loaded (expected in test environment that's fine).
Any other 4xx/5xx (except 503) = regression in request handling.
"""
resp = requests.post(
_url("/v1/chat/completions"),
headers = {**auth_headers, "Content-Type": "application/json"},
json = {
"messages": [{"role": "user", "content": "Hello"}],
"stream": False,
},
timeout = 15,
)
allowed = {200, 400, 503}
assert resp.status_code in allowed, (
f"Unexpected status {resp.status_code} for local inference path: {resp.text[:300]}\n"
f"This likely means the provider fields broke the base request schema."
)
status_label = (
"local model responded"
if resp.status_code == 200
else "no model loaded (expected)"
)
print(f"\n status={resp.status_code} ({status_label}) — local path unaffected")

View file

@ -0,0 +1,241 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for the sandboxed-Python AST policy in core/inference/tools.py."""
import os
import sys
from pathlib import Path
import pytest
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from core.inference.tools import _check_code_safety
def _ok(code: str):
assert _check_code_safety(code) is None, code
def _blocked(code: str, *, expect_phrase: str):
msg = _check_code_safety(code)
assert msg is not None, code
assert expect_phrase in msg, (expect_phrase, msg)
class TestMetadataHostDenylist:
def test_aws_imds_literal_blocked(self):
_blocked(
'import requests; requests.get("http://169.254.169.254/latest/meta-data/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_gcp_metadata_dns_blocked(self):
_blocked(
'import requests; requests.get("http://metadata.google.internal/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_alibaba_ecs_literal_blocked(self):
_blocked(
'import socket; s=socket.socket(); s.connect(("100.100.100.200", 80))',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_ipv6_imds_literal_blocked(self):
_blocked(
'import urllib.request; urllib.request.urlopen("http://[fd00:ec2::254]/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_metadata_link_local_prefix_blocked(self):
_blocked(
'import requests; requests.get("http://169.254.170.2/v3/")',
expect_phrase = "Blocked: cloud-metadata host",
)
class TestTrustedHostAllowlist:
@pytest.mark.parametrize(
"url",
[
"https://en.wikipedia.org/wiki/Python_(programming_language)",
"https://fr.wikipedia.org/wiki/Python_(langage)",
"https://www.google.com/search?q=foo",
"https://duckduckgo.com/?q=foo",
"https://huggingface.co/unsloth",
"https://cdn-lfs.huggingface.co/repos/abc/def/file.bin",
"https://raw.githubusercontent.com/foo/bar/main/README.md",
"https://api.github.com/repos/foo/bar",
"https://arxiv.org/abs/2401.12345",
"https://export.arxiv.org/abs/2401.12345",
"https://stackoverflow.com/questions/12345",
"https://math.stackexchange.com/questions/12345",
"https://developer.mozilla.org/en-US/docs/Web/JavaScript",
"https://docs.python.org/3/library/asyncio.html",
"https://pypi.org/project/requests/",
"https://files.pythonhosted.org/packages/foo/bar.whl",
"https://www.bbc.com/news",
"https://api.weather.gov/points/40,-90",
"https://numpy.org/doc/stable/",
"https://pytorch.org/docs/stable/index.html",
],
)
def test_trusted_host_passes(self, url):
_ok(f"import requests; requests.get({url!r})")
def test_wikipedia_subdomain_passes(self):
_ok(
'import urllib.request; urllib.request.urlopen("https://m.en.wikipedia.org/wiki/Foo")'
)
def test_hf_co_short_form_passes(self):
_ok('import requests; requests.get("https://hf.co/unsloth/Qwen3.5-4B-GGUF")')
def test_github_io_pages_pass(self):
_ok('import requests; requests.get("https://unslothai.github.io/")')
class TestUntrustedHostBlock:
def test_example_com_blocked(self):
_blocked(
'import requests; requests.get("https://example.com/")',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_random_blog_blocked(self):
_blocked(
'import urllib.request; urllib.request.urlopen("https://random-blog-host.example/")',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_socket_connect_random_host_blocked(self):
_blocked(
'import socket; s=socket.socket(); s.connect(("evil.example", 80))',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_dynamic_url_not_statically_blocked(self):
# Static AST cannot resolve runtime URLs; bash blocklist is the fallback.
_ok('import requests; url = "https://example.com/"; requests.get(url)')
class TestHostNormalization:
def test_trailing_dot_treated_same(self):
_ok('import requests; requests.get("https://wikipedia.org./")')
def test_explicit_port_does_not_unblock_or_misblock(self):
_ok('import requests; requests.get("https://en.wikipedia.org:443/wiki/Foo")')
_blocked(
'import requests; requests.get("https://example.com:8080/")',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_userinfo_at_does_not_smuggle_metadata_host(self):
_blocked(
'import requests; requests.get("https://wikipedia.org@169.254.169.254/latest/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_uppercase_host_normalised(self):
_ok('import requests; requests.get("https://EN.WIKIPEDIA.ORG/wiki/Foo")')
class TestUploadDenylist:
def test_requests_post_files_blocked(self):
_blocked(
(
"import requests\n"
'requests.post("https://huggingface.co/api/repos/upload", '
'files={"f": open("x.bin", "rb")})'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_requests_put_data_bytes_blocked(self):
_blocked(
(
"import requests\n"
'requests.put("https://huggingface.co/api/repos/upload", '
'data=b"\\x00\\x01\\x02")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_requests_post_data_open_handle_blocked(self):
_blocked(
(
"import requests\n"
'requests.post("https://huggingface.co/api/repos/upload", '
'data=open("x.bin", "rb"))'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_httpx_post_files_blocked(self):
_blocked(
(
"import httpx\n"
'httpx.post("https://huggingface.co/api/repos/upload", '
'files={"f": open("x.bin", "rb")})'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_hf_api_upload_file_blocked(self):
_blocked(
(
"from huggingface_hub import HfApi\n"
'HfApi().upload_file(path_or_fileobj="x.bin", '
'path_in_repo="x.bin", repo_id="foo/bar")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_hf_module_upload_folder_blocked(self):
_blocked(
(
"import huggingface_hub\n"
'huggingface_hub.upload_folder(folder_path="./", repo_id="foo/bar")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_hf_create_commit_method_blocked(self):
_blocked(
(
"import huggingface_hub\n"
"api = huggingface_hub.HfApi()\n"
'api.create_commit(repo_id="foo/bar", operations=[])'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_plain_post_json_not_blocked(self):
_ok(
"import requests\n"
'requests.post("https://api.weather.gov/lookup", json={"k": "v"})'
)
class TestSandboxCpuRlimitDefault:
"""Pin the default so a regression below 600s without opt-in is caught."""
def test_default_cpu_s_is_600(self):
src = (_BACKEND_ROOT / "core" / "inference" / "tools.py").read_text()
assert 'UNSLOTH_STUDIO_SANDBOX_CPU_S", "600"' in src
def test_clone_newnet_removed(self):
src = (_BACKEND_ROOT / "core" / "inference" / "tools.py").read_text()
assert "_libc.unshare(0x40000000)" not in src
# Explanatory comment retained.
assert "CLONE_NEWNET" in src
class TestMaxBodyDefault:
def test_default_is_500_mb(self):
src = (_BACKEND_ROOT / "main.py").read_text()
assert 'UNSLOTH_STUDIO_MAX_BODY_MB", "500"' in src

View file

@ -0,0 +1,90 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Pin TrainingStartRequest hyperparameter caps at the at-cap / over-cap boundary."""
import sys
from pathlib import Path
import pytest
from pydantic import ValidationError
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from models.training import (
_MAX_BATCH_SIZE,
_MAX_LORA_ALPHA,
_MAX_LORA_R,
_MAX_SEQ_LENGTH,
)
def _check_field(field_name: str, value):
"""Run the field validator without constructing a full TrainingStartRequest."""
from models.training import TrainingStartRequest
schema_field = TrainingStartRequest.model_fields[field_name]
return TrainingStartRequest.__pydantic_validator__.validate_assignment(
TrainingStartRequest.model_construct(),
field_name,
value,
)
class TestSeqLengthCap:
def test_at_cap_accepts(self):
_check_field("max_seq_length", _MAX_SEQ_LENGTH)
assert _MAX_SEQ_LENGTH == 2_000_000
def test_over_cap_rejects(self):
with pytest.raises(ValidationError) as exc:
_check_field("max_seq_length", _MAX_SEQ_LENGTH + 1)
assert "max_seq_length" in str(exc.value)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("max_seq_length", 0)
class TestBatchSizeCap:
def test_at_cap_accepts(self):
_check_field("batch_size", _MAX_BATCH_SIZE)
assert _MAX_BATCH_SIZE == 4096
def test_over_cap_rejects(self):
with pytest.raises(ValidationError):
_check_field("batch_size", _MAX_BATCH_SIZE + 1)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("batch_size", 0)
class TestLoraRCap:
def test_at_cap_accepts(self):
_check_field("lora_r", _MAX_LORA_R)
assert _MAX_LORA_R == 16_384
def test_over_cap_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_r", _MAX_LORA_R + 1)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_r", 0)
class TestLoraAlphaCap:
def test_at_cap_accepts(self):
_check_field("lora_alpha", _MAX_LORA_ALPHA)
assert _MAX_LORA_ALPHA == 32_768
def test_over_cap_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_alpha", _MAX_LORA_ALPHA + 1)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_alpha", 0)

View file

@ -28,7 +28,16 @@ from utils.models.model_config import (
)
def test_scan_trained_models_includes_lora_and_full_finetune_outputs(tmp_path: Path):
def test_scan_trained_models_includes_lora_and_full_finetune_outputs(
tmp_path: Path, monkeypatch
):
# resolve_output_dir refuses absolutes outside outputs_root; point it at tmp_path.
from utils.models import model_config as _mc
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: tmp_path)
monkeypatch.setattr(_mc, "outputs_root", lambda: tmp_path)
lora_dir = tmp_path / "unsloth_SmolLM-135M_1775412608"
lora_dir.mkdir()
(lora_dir / "adapter_config.json").write_text(

View file

@ -70,6 +70,48 @@ class TestTrainingRawSupport(unittest.TestCase):
self.assertTrue(config["load_in_4bit"])
self.assertEqual(config["embedding_learning_rate"], 1e-5)
def test_training_backend_forwards_grad_clipping_controls(self):
backend = TrainingBackend()
class DummyProcess:
pid = 12345
def start(self):
return None
class DummyThread:
def start(self):
return None
dummy_queue = object()
with (
patch(
"core.training.training.prepare_gpu_selection",
return_value = ([0], {"selection_mode": "auto"}),
),
patch(
"core.training.training._CTX.Queue",
side_effect = [dummy_queue, dummy_queue],
),
patch(
"core.training.training._CTX.Process", return_value = DummyProcess()
) as mock_process,
patch(
"core.training.training.threading.Thread",
return_value = DummyThread(),
),
):
backend.start_training(
job_id = "test-grad-clip",
model_name = "unsloth/test",
training_type = "LoRA/QLoRA",
max_grad_norm = 0.7,
)
config = mock_process.call_args.kwargs["kwargs"]["config"]
self.assertEqual(config["max_grad_norm"], 0.7)
def test_training_route_forwards_embedding_learning_rate(self):
training_route = _load_route_module(
"training_route_module_raw_support",

View file

@ -37,6 +37,7 @@ def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
statuses: list[str] = []
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
monkeypatch.setattr(
worker,
@ -65,6 +66,7 @@ def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
statuses: list[str] = []
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
monkeypatch.setattr(
worker,
@ -112,6 +114,29 @@ def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
worker._sp.run.assert_not_called()
def test_runtime_flash_attn_skips_on_blackwell(monkeypatch):
statuses: list[str] = []
install_mock = mock.Mock()
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
monkeypatch.setattr(
worker, "_should_try_runtime_flash_attn_install", lambda max_seq: True
)
monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
monkeypatch.setattr(
worker,
"_send_status",
lambda queue, message: statuses.append(message),
)
worker._ensure_flash_attn_for_long_context(event_queue = [], max_seq_length = 65536)
install_mock.assert_not_called()
assert len(statuses) == 1
assert "Blackwell" in statuses[0]
def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
install_mock = mock.Mock(return_value = True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)

View file

@ -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
"""Build-stamped Studio release metadata.
Release builds may rewrite this module in the build workspace before creating
Python artifacts. Keep the committed value neutral so source checkouts do not
accidentally report a stale release tag.
"""
STUDIO_RELEASE_VERSION = None

View file

@ -28,6 +28,23 @@ DEFAULT_ALPACA_TEMPLATE = """Below is an instruction that describes a task, pair
{}"""
def _is_mlx_runtime() -> bool:
try:
from unsloth_zoo.mlx import is_mlx_available
except ImportError:
return False
return is_mlx_available()
def _chat_template_kwargs() -> dict:
if not _is_mlx_runtime():
return {}
return {
"patch_saving": False,
"use_zoo_tokenizer_patch": True,
}
def get_tokenizer_chat_template(tokenizer, model_name):
"""
Gets appropriate chat template for tokenizer based on model.
@ -60,6 +77,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
tokenizer = get_chat_template(
tokenizer,
chat_template = matched_template,
**_chat_template_kwargs(),
)
except Exception as e:
logger.info(f"⚠️ Failed to apply Unsloth template '{matched_template}': {e}")
@ -79,6 +97,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
tokenizer = get_chat_template(
tokenizer,
chat_template = "chatml",
**_chat_template_kwargs(),
)
except Exception as e:
logger.info(f"⚠️ Failed to apply default ChatML template: {e}")
@ -255,7 +274,11 @@ def apply_chat_template_to_dataset(
if not (hasattr(tokenizer, 'chat_template') and tokenizer.chat_template):
try:
from unsloth.chat_templates import get_chat_template
tokenizer = get_chat_template(tokenizer, chat_template = "alpaca")
tokenizer = get_chat_template(
tokenizer,
chat_template = "alpaca",
**_chat_template_kwargs(),
)
logger.info(f"📝 Set alpaca chat template on tokenizer for model saving")
except Exception as e:
logger.info(f"⚠️ Could not set alpaca template on tokenizer: {e}")

View file

@ -1327,16 +1327,42 @@ def detect_gguf_model_remote(
Check if a HuggingFace repo contains GGUF files.
Returns the filename of the best GGUF file in the repo, or None.
"""
try:
from huggingface_hub import model_info as hf_model_info
info = hf_model_info(repo_id, token = hf_token)
repo_files = [s.rfilename for s in info.siblings]
return _pick_best_gguf(repo_files)
except Exception as e:
logger.debug(f"Could not check GGUF files for '{repo_id}': {e}")
return None
Retries on transient HF Hub failures (network hiccups, 5xx, slow
cold-start of the API). Without retry, a single transient failure
here returns None silently and the caller treats the repo as
non-GGUF -- which on Apple Silicon (Mac UI route) means falling
through to the MLX backend, which then fails opening a non-existent
config.json on the GGUF-only repo. Three attempts with 1s/2s/4s
backoff covers the typical free-runner HF Hub flakiness.
"""
import time
from huggingface_hub import model_info as hf_model_info
last_err: Optional[Exception] = None
for attempt in range(3):
try:
info = hf_model_info(repo_id, token = hf_token)
repo_files = [s.rfilename for s in info.siblings]
return _pick_best_gguf(repo_files)
except Exception as e:
last_err = e
# 404 / RepoNotFound is permanent -- don't waste attempts.
err_name = type(e).__name__
if err_name in (
"RepositoryNotFoundError",
"GatedRepoError",
"RevisionNotFoundError",
"EntryNotFoundError",
):
logger.debug(f"Could not check GGUF files for '{repo_id}': {e}")
return None
if attempt < 2:
time.sleep(2**attempt)
logger.warning(
f"Could not check GGUF files for '{repo_id}' after 3 attempts: " f"{last_err}"
)
return None
def download_gguf_file(
@ -1670,20 +1696,21 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
)
return base_model
training_args_path = checkpoint_path_obj / "training_args.bin"
if training_args_path.exists():
try:
import torch
training_args = torch.load(training_args_path)
if hasattr(training_args, "model_name_or_path"):
base_model = training_args.model_name_or_path
logger.info(
"Detected base model from training_args.bin: %s", base_model
)
return base_model
except Exception as e:
logger.warning(f"Could not load training_args.bin: {e}")
# TODO: torch.load default weights_only=True (torch >= 2.6) rejects pickled TrainingArguments; re-enable via safe_globals or weights_only=False once threat model allows.
# training_args_path = checkpoint_path_obj / "training_args.bin"
# if training_args_path.exists():
# try:
# import torch
#
# training_args = torch.load(training_args_path)
# if hasattr(training_args, "model_name_or_path"):
# base_model = training_args.model_name_or_path
# logger.info(
# "Detected base model from training_args.bin: %s", base_model
# )
# return base_model
# except Exception as e:
# logger.warning(f"Could not load training_args.bin: {e}")
dir_name = checkpoint_path_obj.name
if dir_name.startswith("unsloth_"):
@ -1731,20 +1758,21 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
return base_model
# Fallback: try training_args.bin (requires torch)
training_args_path = lora_path_obj / "training_args.bin"
if training_args_path.exists():
try:
import torch
training_args = torch.load(training_args_path)
if hasattr(training_args, "model_name_or_path"):
base_model = training_args.model_name_or_path
logger.info(
f"Detected base model from training_args.bin: {base_model}"
)
return base_model
except Exception as e:
logger.warning(f"Could not load training_args.bin: {e}")
# TODO: torch.load default weights_only=True (torch >= 2.6) rejects pickled TrainingArguments; also an RCE sink for third-party LoRAs via this route, re-enable behind a trust check if needed.
# training_args_path = lora_path_obj / "training_args.bin"
# if training_args_path.exists():
# try:
# import torch
#
# training_args = torch.load(training_args_path)
# if hasattr(training_args, "model_name_or_path"):
# base_model = training_args.model_name_or_path
# logger.info(
# f"Detected base model from training_args.bin: {base_model}"
# )
# return base_model
# except Exception as e:
# logger.warning(f"Could not load training_args.bin: {e}")
# Last resort: parse from directory name
# Format: unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit_timestamp

View file

@ -276,21 +276,52 @@ def _clean_relative_path(
return Path(*parts) if parts else Path()
def _assert_contained(resolved: Path, root: Path) -> None:
"""Raise ValueError if ``resolved`` realpaths outside ``root``."""
try:
resolved_real = Path(os.path.realpath(resolved))
root_real = Path(os.path.realpath(root))
except OSError as exc:
raise ValueError(f"path resolution failed: {exc}") from exc
try:
resolved_real.relative_to(root_real)
except ValueError as exc:
raise ValueError(
f"path escapes root: {resolved!s} -> {resolved_real!s} "
f"is not under {root_real!s}"
) from exc
def resolve_under_root(
path_value: str | None,
*,
root: Path,
strip_prefixes: tuple[str, ...] = (),
) -> Path:
"""Resolve ``path_value`` and assert the result is under ``root``.
Absolutes are accepted only if already contained (so internal pre-resolved
paths re-enter idempotently); user-facing schemas reject absolutes upstream.
"""
if not path_value or not str(path_value).strip():
return root
path = Path(str(path_value).strip()).expanduser()
raw = str(path_value).strip()
if "\x00" in raw:
raise ValueError("path may not contain null bytes")
path = Path(raw).expanduser()
if ".." in path.parts:
raise ValueError(f"path may not contain '..' segments: {raw!r}")
if path.is_absolute():
_assert_contained(path, root)
return path
cleaned = _clean_relative_path(str(path), strip_prefixes = strip_prefixes)
return root / cleaned
cleaned = _clean_relative_path(raw, strip_prefixes = strip_prefixes)
candidate = root / cleaned
_assert_contained(candidate, root)
return candidate
def resolve_output_dir(path_value: str | None = None) -> Path:
@ -318,9 +349,22 @@ def resolve_tensorboard_dir(path_value: str | None = None) -> Path:
def resolve_dataset_path(path_value: str) -> Path:
path = Path(path_value).expanduser()
raw = str(path_value or "").strip()
if "\x00" in raw:
raise ValueError("dataset path may not contain null bytes")
path = Path(raw).expanduser()
if ".." in path.parts:
raise ValueError(f"dataset path may not contain '..' segments: {raw!r}")
if path.is_absolute():
return path
for root_fn in (datasets_root, dataset_uploads_root, recipe_datasets_root):
try:
_assert_contained(path, root_fn())
return path
except ValueError:
continue
raise ValueError(
f"dataset path must be relative or under a dataset root: {raw!r}"
)
parts = [part for part in Path(path_value).parts if part not in ("", ".")]
if parts[:2] == ["assets", "datasets"]:

View file

@ -0,0 +1,92 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Network-free Studio release version resolution for display-only UI."""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from utils import _studio_release_build
_DEV_VERSION = "dev"
_GIT_TIMEOUT_SECONDS = 1.0
_STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
_GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
_MAX_VERSION_LENGTH = 64
def is_valid_studio_release_version(value: object) -> bool:
"""Return True for Studio release tags such as ``v0.1.39-beta``."""
if not isinstance(value, str):
return False
version = value.strip()
if not version or len(version) > _MAX_VERSION_LENGTH:
return False
if version.endswith("-dirty") or _GIT_DESCRIBE_SUFFIX_RE.search(version):
return False
return _STUDIO_TAG_RE.fullmatch(version) is not None
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _path_is_in_site_packages(path: Path) -> bool:
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
def _is_source_checkout(repo_root: Path) -> bool:
return (repo_root / ".git").exists() and not _path_is_in_site_packages(
Path(__file__).resolve()
)
def _exact_git_studio_tag(repo_root: Path) -> str | None:
try:
result = subprocess.run(
[
"git",
"describe",
"--tags",
"--exact-match",
"--match",
"v[0-9]*",
"HEAD",
],
cwd = repo_root,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
tag = result.stdout.strip()
return tag if is_valid_studio_release_version(tag) else None
def get_studio_version(repo_root: Path | None = None) -> str:
"""Return the installed Studio release tag for display, or ``dev``.
This value is intentionally separate from the PyPI ``unsloth`` package
version used by update checks. It never performs network requests.
"""
resolved_repo_root = repo_root or _repo_root()
if _is_source_checkout(resolved_repo_root):
git_tag = _exact_git_studio_tag(resolved_repo_root)
return git_tag if git_tag is not None else _DEV_VERSION
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
if is_valid_studio_release_version(stamped_version):
return stamped_version.strip()
return _DEV_VERSION

View file

@ -0,0 +1,374 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Web update status helpers for browser-served Unsloth Studio.
This module is intentionally side-effect light: no network work happens at
import time or from /api/health. The PyPI check is lazy, cached, and only used
for normal PyPI-managed installs.
"""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from typing import Any
from packaging.version import InvalidVersion, Version
PACKAGE_NAME = "unsloth"
PYPI_JSON_URL = "https://pypi.org/pypi/unsloth/json"
PYPI_TIMEOUT_SECONDS = 3
PYPI_RESPONSE_MAX_BYTES = 5 * 1024 * 1024
PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
PYPI_FAILURE_TTL_SECONDS = 60 * 60
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
@dataclass(frozen = True)
class LatestVersionResult:
latest_version: str | None
checked_at: str
reason: str | None = None
error: str | None = None
@dataclass
class _LatestVersionCacheEntry:
result: LatestVersionResult
expires_at: float
_cache_condition = threading.Condition()
_latest_version_cache: _LatestVersionCacheEntry | None = None
_latest_version_fetching = False
def reset_update_status_cache() -> None:
"""Clear the in-process PyPI cache. Intended for tests."""
global _latest_version_cache, _latest_version_fetching
with _cache_condition:
_latest_version_cache = None
_latest_version_fetching = False
_cache_condition.notify_all()
def detect_install_source() -> str:
"""Return a coarse install source without exposing local paths.
Sources are intentionally conservative. PEP 610 local/vcs metadata wins.
Legacy source installs are treated as local only when package files resolve
outside site-packages/dist-packages and under a Git checkout.
"""
try:
dist = distribution(PACKAGE_NAME)
except PackageNotFoundError:
return (
"local_repo"
if _path_has_git_parent(_repo_root_from_this_file())
else "unknown"
)
try:
direct_url = dist.read_text("direct_url.json")
except Exception:
return "unknown"
if direct_url:
return _source_from_direct_url(direct_url)
for package_path in _distribution_package_paths(dist):
if not _path_is_under_python_package_dir(package_path) and _path_has_git_parent(
package_path
):
return "local_repo"
return "pypi"
def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
"""Return install-source metadata without remote update checks."""
install_source = detect_install_source()
reason = None
if install_source in LOCAL_INSTALL_SOURCES:
reason = "local_source"
elif install_source == "unknown":
reason = "unknown_source"
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = reason,
)
def get_studio_update_status(current_version: str) -> dict[str, Any]:
"""Return public, read-only update status for the web UI."""
install_source = detect_install_source()
if os.environ.get(DISABLE_ENV_VAR) == "1":
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "disabled",
)
if install_source in LOCAL_INSTALL_SOURCES:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "local_source",
)
if install_source != "pypi":
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "unknown_source",
)
current = _parse_current_version(current_version)
if current is None:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "invalid_current_version"
if current_version != "dev"
else "dev_build",
)
latest_result = get_latest_pypi_version()
if latest_result.latest_version is None:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = latest_result.reason or "offline",
error = latest_result.error,
checked_at = latest_result.checked_at,
)
try:
latest = Version(latest_result.latest_version)
except InvalidVersion:
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
reason = "invalid_latest_version",
error = "PyPI returned an invalid version.",
checked_at = latest_result.checked_at,
)
if latest > current:
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
update_available = True,
can_show_web_notification = True,
checked_at = latest_result.checked_at,
)
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
reason = "current_not_older",
checked_at = latest_result.checked_at,
)
def get_latest_pypi_version() -> LatestVersionResult:
"""Return the latest PyPI version using a small in-process TTL cache."""
global _latest_version_cache, _latest_version_fetching
while True:
now = time.monotonic()
with _cache_condition:
if _latest_version_cache and _latest_version_cache.expires_at > now:
return _latest_version_cache.result
if not _latest_version_fetching:
_latest_version_fetching = True
break
_cache_condition.wait(timeout = PYPI_TIMEOUT_SECONDS + 1)
try:
result = _fetch_latest_pypi_version()
except Exception:
result = LatestVersionResult(
latest_version = None,
checked_at = _utc_now_iso(),
reason = "offline",
error = "Could not check PyPI update metadata.",
)
ttl = (
PYPI_SUCCESS_TTL_SECONDS if result.latest_version else PYPI_FAILURE_TTL_SECONDS
)
with _cache_condition:
_latest_version_cache = _LatestVersionCacheEntry(
result = result,
expires_at = time.monotonic() + ttl,
)
_latest_version_fetching = False
_cache_condition.notify_all()
return result
def _fetch_latest_pypi_version() -> LatestVersionResult:
checked_at = _utc_now_iso()
request = urllib.request.Request(
PYPI_JSON_URL,
headers = {"User-Agent": "unsloth-studio-update-check"},
)
try:
with urllib.request.urlopen(request, timeout = PYPI_TIMEOUT_SECONDS) as response:
body = response.read(PYPI_RESPONSE_MAX_BYTES + 1)
if len(body) > PYPI_RESPONSE_MAX_BYTES:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI returned oversized update metadata.",
)
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI returned malformed update metadata.",
)
except OSError:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "offline",
error = "Could not reach PyPI for update metadata.",
)
latest = (
payload.get("info", {}).get("version") if isinstance(payload, dict) else None
)
if not isinstance(latest, str) or not latest.strip():
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI update metadata did not include a version.",
)
return LatestVersionResult(latest_version = latest.strip(), checked_at = checked_at)
def _status_response(
*,
current_version: str,
latest_version: str | None,
install_source: str,
reason: str | None = None,
error: str | None = None,
update_available: bool = False,
can_show_web_notification: bool = False,
checked_at: str | None = None,
) -> dict[str, Any]:
return {
"current_version": current_version,
"latest_version": latest_version,
"update_available": update_available,
"install_source": install_source,
"can_show_web_notification": can_show_web_notification,
"release_notes_url": RELEASE_NOTES_URL,
"checked_at": checked_at or _utc_now_iso(),
"reason": reason,
"error": error,
}
def _source_from_direct_url(direct_url: str) -> str:
try:
payload = json.loads(direct_url)
except json.JSONDecodeError:
return "unknown"
if not isinstance(payload, dict):
return "unknown"
dir_info = payload.get("dir_info")
if isinstance(dir_info, dict) and dir_info.get("editable") is True:
return "editable"
if isinstance(payload.get("vcs_info"), dict):
return "vcs"
url = payload.get("url")
if isinstance(url, str) and url.startswith("file:"):
return "local_path"
return "unknown"
def _distribution_package_paths(dist: Any) -> list[Path]:
paths: list[Path] = []
files = getattr(dist, "files", None) or []
for file in files:
text = str(file)
if not text.startswith(("unsloth/", "unsloth_cli/", "studio/")):
continue
try:
paths.append(Path(dist.locate_file(file)).resolve())
except OSError:
continue
return paths
def _path_is_under_python_package_dir(path: Path) -> bool:
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
def _path_has_git_parent(path: Path) -> bool:
for candidate in (path, *path.parents):
if (candidate / ".git").exists():
return True
return False
def _repo_root_from_this_file() -> Path:
# update_status.py -> utils -> backend -> studio -> repo root
try:
return Path(__file__).resolve().parents[3]
except IndexError:
return Path(__file__).resolve().parent
def _parse_current_version(current_version: str) -> Version | None:
if current_version == "dev":
return None
try:
return Version(current_version)
except InvalidVersion:
return None
def _utc_now_iso() -> str:
return (
datetime.now(timezone.utc)
.replace(microsecond = 0)
.isoformat()
.replace("+00:00", "Z")
)

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import functools
import json
import logging
import platform
@ -23,6 +24,49 @@ FLASH_ATTN_RELEASE_BASE_URL = (
)
@functools.lru_cache(maxsize = 1)
def has_blackwell_gpu() -> bool:
"""Return True if any visible NVIDIA GPU has compute capability >= 10.0
(Blackwell: sm_100, sm_120, sm_121, ...).
Dao-AILab does not publish prebuilt flash-attention wheels for these
architectures, and the older-arch wheels fail to load on Blackwell, so
callers use this gate to skip the flash-attn install/upgrade path.
Result is cached for the process lifetime since GPU hardware does not
change. Tests that mock subprocess/nvidia-smi must call
``has_blackwell_gpu.cache_clear()`` before each invocation.
"""
exe = shutil.which("nvidia-smi")
if not exe:
return False
try:
result = subprocess.run(
[exe, "--query-gpu=compute_cap", "--format=csv,noheader"],
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = 10,
env = child_env_without_native_path_secret(),
)
except (OSError, subprocess.TimeoutExpired):
return False
if result.returncode != 0:
return False
for line in result.stdout.splitlines():
cap = line.strip()
if not cap:
continue
major_part = cap.split(".", 1)[0]
try:
major = int(major_part)
except ValueError:
continue
if major >= 10:
return True
return False
def linux_wheel_platform_tag() -> str | None:
machine = platform.machine().lower()
if sys.platform.startswith("linux"):

28
studio/frontend/.npmrc Normal file
View file

@ -0,0 +1,28 @@
# Studio frontend npm configuration.
#
# Mini Shai-Hulud / Axios-style supply chain defense.
# Requires npm >=11.10.0. Refuses tarballs published less than 7 days ago,
# closing the typical 4-72h attack window between malicious publish and
# upstream removal. npm interprets the bare integer as DAYS; do not
# append `d`, npm 11.x will parse `7d` as a Date string and abort.
min-release-age=7
# Defensive alias: `minimum-release-age` takes minutes (10080 = 7 days).
# Some npm versions / wrappers consult one key but not the other; setting
# both means a single setting-name parse change upstream cannot silently
# disable the cooldown. The two keys MUST agree; do not let them drift.
minimum-release-age=10080
# Belt-and-braces: refuse to write back loose `^x.y.z` ranges into
# package.json when a maintainer runs `npm install <pkg>` locally. This
# does NOT rewrite already-present ranges (those need an explicit
# `npm install <name>@<version> --save-exact` pass) but it stops new
# carets from creeping into the manifest as patch-version footguns.
save-exact=true
# Lock the registry. A user-set PIP_INDEX_URL-style override (here:
# NPM_CONFIG_REGISTRY env var or a stale ~/.npmrc) shouldn't redirect
# our installs to an attacker registry.
registry=https://registry.npmjs.org/
audit-level=high
fund=false
# Maintainer note: use `npm ci` (never `npm install`) in CI and locally
# when reproducing a build. The 7-day cooldown above is enforced by npm
# itself; downgrading or removing it bypasses the supply-chain gate.

View file

@ -58,6 +58,7 @@
"motion": "^12.34.0",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
@ -80,6 +81,7 @@
"@eslint/js": "^9.39.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.5.2",
"@types/node-forge": "^1.3.14",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@ -7377,6 +7379,16 @@
"undici-types": "~7.19.0"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -13285,6 +13297,15 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",

View file

@ -41,7 +41,7 @@
"@streamdown/math": "1.0.2",
"@streamdown/mermaid": "1.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.159.10",
"@tanstack/react-router": "1.169.2",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
@ -66,6 +66,7 @@
"motion": "^12.34.0",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
@ -83,10 +84,16 @@
"unpdf": "^1.4.0",
"zustand": "^5.0.11"
},
"overrides": {
"@tanstack/react-router": "1.169.2",
"@tanstack/router-core": "1.169.2",
"@tanstack/history": "1.161.6"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.39.1",
"@types/js-yaml": "^4.0.9",
"@types/node-forge": "^1.3.14",
"@types/node": "^25.5.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",

View file

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

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

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

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

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

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

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

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

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

After

Width:  |  Height:  |  Size: 399 B

View file

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

After

Width:  |  Height:  |  Size: 11 KiB

View file

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

After

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

View file

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

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -9,6 +9,7 @@ import {
shouldUseCustomWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { Toaster } from "@/components/ui/sonner";
import { WebUpdateBanner } from "@/components/web/update-banner";
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend";
@ -22,10 +23,6 @@ interface AppProviderProps {
children: ReactNode;
}
// ---------------------------------------------------------------------------
// Tauri window helpers (only imported in Tauri mode)
// ---------------------------------------------------------------------------
type TauriWindowMode = "setup" | "app";
type WindowLayoutGuard = () => boolean;
@ -52,19 +49,15 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
let finalH = 600;
if (monitor) {
// Convert physical pixels to logical using scale factor
const scale = monitor.scaleFactor;
const screenW = monitor.size.width / scale;
const screenH = monitor.size.height / scale;
// Target: 75% of screen width, golden ratio height, capped at min 900x600
finalW = Math.max(900, Math.round(screenW * 0.75));
const targetH = Math.max(600, Math.round(finalW / 1.618));
// Don't exceed screen height
finalH = Math.min(targetH, Math.round(screenH * 0.85));
}
// Apply constraints and finalize without animating through intermediate sizes
if (!isCurrent()) return;
await win.setSize(new LogicalSize(finalW, finalH));
if (!isCurrent()) return;
@ -107,10 +100,6 @@ function getTauriWindowMode(
}
}
// ---------------------------------------------------------------------------
// TauriWrapper
// ---------------------------------------------------------------------------
function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
const update = useTauriUpdate(isExternalServer);
const isUpdating =
@ -140,6 +129,8 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
dismissed={update.dismissed}
lastFailure={update.lastFailure}
isExternalServer={isExternalServer}
updatePolicyMode={update.updatePolicyMode}
manualReleaseUrl={update.manualReleaseUrl}
onInstall={update.installUpdate}
onDismiss={update.dismiss}
onCopyDiagnostics={update.copyDiagnostics}
@ -154,6 +145,13 @@ const HIDDEN_TITLEBAR_SIDEBAR_ROUTES = new Set([
"/signup",
]);
const WEB_UPDATE_HIDDEN_ROUTES = new Set([
"/onboarding",
"/login",
"/change-password",
"/signup",
]);
function TauriWrapper({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const {
@ -176,8 +174,7 @@ function TauriWrapper({ children }: { children: ReactNode }) {
};
}, []);
// Keep the Tauri window hidden during preflight, then show it centered in setup
// mode or apply the final app layout in one instant step.
// Keep the Tauri window hidden until setup or app layout is ready.
useEffect(() => {
if (!isTauri) return;
@ -234,7 +231,14 @@ function TauriWrapper({ children }: { children: ReactNode }) {
return () => { disposed = true; };
}, [status, desktopAuthRetry]);
if (!isTauri) return <>{children}</>;
if (!isTauri) {
return (
<>
{children}
<WebUpdateBanner enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)} />
</>
);
}
const showApp = status === "running" && desktopAuthReady;
const startupStatus = status === "running" ? "starting" : status;

View file

@ -527,6 +527,9 @@ export function AppSidebar() {
{chatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
data-testid="recent-thread"
data-thread-type={item.type}
data-thread-id={item.id}
isActive={activeThreadId === item.id}
className="sidebar-nav-btn h-[32px] rounded-[10px] pl-2.5 pr-2.5 group-hover/recent-item:pr-10 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-10 text-[14.5px] leading-[19px] tracking-nav font-medium"
onClick={() => {

View file

@ -13,21 +13,71 @@ import { usePlatformStore } from "@/config/env";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
CloudIcon,
FolderSearchIcon,
Logout01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useState } from "react";
import type {
DeletedModelRef,
ExternalModelOption,
LoraModelOption,
ModelOption,
ModelSelectorChangeMeta,
} from "./model-selector/types";
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
import { Input } from "../ui/input";
const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = {
openai: "svg",
mistral: "svg",
gemini: "svg",
anthropic: "svg",
deepseek: "svg",
huggingface: "svg",
kimi: "jpg",
qwen: "png",
openrouter: "svg",
};
function providerLogoSrc(providerType: string | undefined): string | undefined {
if (!providerType) return undefined;
const ext = PROVIDER_LOGO_EXT[providerType];
if (!ext) return undefined;
return `${import.meta.env.BASE_URL}provider-logos/${providerType}.${ext}`;
}
function ExternalProviderLogo({
providerType,
className,
title,
}: {
providerType: string | undefined;
className?: string;
title?: string;
}) {
const src = providerLogoSrc(providerType);
if (!src) return null;
return (
<img
src={src}
alt=""
title={title}
aria-hidden={true}
className={cn(
"shrink-0 object-contain",
providerType === "openai" && "dark:invert",
className,
)}
/>
);
}
export type {
DeletedModelRef,
ExternalModelOption,
LoraModelOption,
ModelOption,
ModelSelectorChangeMeta,
@ -36,6 +86,7 @@ export type {
interface ModelSelectorProps {
models: ModelOption[];
loraModels?: LoraModelOption[];
externalModels?: ExternalModelOption[];
value?: string;
defaultValue?: string;
activeGgufVariant?: string | null;
@ -53,11 +104,13 @@ interface ModelSelectorProps {
onOpenChange?: (open: boolean) => void;
triggerDataTour?: string;
contentDataTour?: string;
showCloudIndicator?: boolean;
}
function ModelSelectorTrigger({
currentModel,
isLoaded,
showCloudIndicator = false,
variant = "outline",
size = "default",
className,
@ -65,6 +118,7 @@ function ModelSelectorTrigger({
}: {
currentModel?: ModelOption;
isLoaded: boolean;
showCloudIndicator?: boolean;
variant?: "outline" | "ghost" | "muted";
size?: "sm" | "default" | "lg";
className?: string;
@ -90,12 +144,27 @@ function ModelSelectorTrigger({
{isLoaded && (
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
{currentModel?.icon ? (
<span className="flex shrink-0 items-center">{currentModel.icon}</span>
) : null}
<span className="flex min-w-0 flex-1 items-baseline">
<span className="min-w-0 flex flex-1 items-baseline truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
{currentModel?.name ?? "Select model"}
{showCloudIndicator ? (
<HugeiconsIcon
icon={CloudIcon}
strokeWidth={1.75}
className="relative top-[0.15625rem] ml-1.5 mr-[0.36rem] size-3.5 shrink-0 text-muted-foreground"
/>
) : null}
</span>
{currentModel?.description && (
<span className="shrink-0 text-xs leading-none text-muted-foreground">
<span
className={cn(
"shrink-0 text-xs leading-none text-muted-foreground",
showCloudIndicator ? "" : "ml-2",
)}
>
{currentModel.description}
</span>
)}
@ -115,6 +184,7 @@ function ModelSelectorTrigger({
function ModelSelectorContent({
models,
loraModels,
externalModels,
value,
onSelect,
onEject,
@ -127,6 +197,7 @@ function ModelSelectorContent({
}: {
models: ModelOption[];
loraModels: LoraModelOption[];
externalModels: ExternalModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
onEject?: () => void;
@ -139,6 +210,20 @@ function ModelSelectorContent({
}) {
const hasSelection = Boolean(value);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const hasExternal = externalModels.length > 0;
const chatOnlyTabsDefault = useMemo(
() => (value && externalModels.some((model) => model.id === value) ? "external" : "hub"),
[externalModels, value],
);
const studioTabsDefault = useMemo((): "hub" | "lora" | "external" => {
if (value && externalModels.some((model) => model.id === value)) {
return "external";
}
if (value && loraModels.some((model) => model.id === value)) {
return "lora";
}
return "hub";
}, [externalModels, loraModels, value]);
return (
<PopoverContent
@ -150,12 +235,32 @@ function ModelSelectorContent({
)}
>
{chatOnly ? (
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
hasExternal ? (
<Tabs defaultValue={chatOnlyTabsDefault} className="w-full">
<TabsList className="mb-2 w-full">
<TabsTrigger value="hub">Hub models</TabsTrigger>
<TabsTrigger value="external">External</TabsTrigger>
</TabsList>
<TabsContent value="hub" className="m-0">
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
</TabsContent>
<TabsContent value="external" className="m-0">
<ExternalModelPicker
externalModels={externalModels}
value={value}
onSelect={onSelect}
/>
</TabsContent>
</Tabs>
) : (
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
)
) : (
<Tabs defaultValue="hub" className="w-full">
<Tabs defaultValue={studioTabsDefault} className="w-full">
<TabsList className="mb-2 w-full">
<TabsTrigger value="hub">Hub models</TabsTrigger>
<TabsTrigger value="lora">Fine-tuned</TabsTrigger>
{hasExternal ? <TabsTrigger value="external">External</TabsTrigger> : null}
</TabsList>
<TabsContent value="hub" className="m-0">
@ -171,6 +276,16 @@ function ModelSelectorContent({
deleteDisabled={deleteDisabled}
/>
</TabsContent>
{hasExternal ? (
<TabsContent value="external" className="m-0">
<ExternalModelPicker
externalModels={externalModels}
value={value}
onSelect={onSelect}
/>
</TabsContent>
) : null}
</Tabs>
)}
@ -207,6 +322,7 @@ function ModelSelectorContent({
export function ModelSelector({
models,
loraModels = [],
externalModels = [],
value,
defaultValue,
activeGgufVariant,
@ -224,6 +340,7 @@ export function ModelSelector({
onOpenChange,
triggerDataTour,
contentDataTour,
showCloudIndicator = false,
}: ModelSelectorProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen;
@ -266,8 +383,21 @@ export function ModelSelector({
description: tag,
});
}
for (const externalModel of externalModels) {
all.set(externalModel.id, {
...externalModel,
description: externalModel.providerName,
icon: (
<ExternalProviderLogo
providerType={externalModel.providerType}
className="size-4"
title={externalModel.providerName}
/>
),
});
}
return all;
}, [loraModels, models]);
}, [externalModels, loraModels, models]);
const currentModel = useMemo(() => {
if (!selected) return undefined;
@ -303,6 +433,7 @@ export function ModelSelector({
<ModelSelectorTrigger
currentModel={currentModel}
isLoaded={isLoaded}
showCloudIndicator={showCloudIndicator}
variant={variant}
size={size}
className={className}
@ -311,6 +442,7 @@ export function ModelSelector({
<ModelSelectorContent
models={models}
loraModels={loraModels}
externalModels={externalModels}
value={selected}
onSelect={handleSelect}
onEject={onEject ? handleEject : undefined}
@ -327,3 +459,105 @@ export function ModelSelector({
ModelSelector.Trigger = ModelSelectorTrigger;
ModelSelector.Content = ModelSelectorContent;
function normalizeForSearch(value: string): string {
return value.toLowerCase().replace(/[\s_.-]/g, "");
}
function ExternalModelPicker({
externalModels,
value,
onSelect,
}: {
externalModels: ExternalModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const grouped = useMemo(() => {
const needle = normalizeForSearch(query.trim());
const byProvider = new Map<
string,
{ providerName: string; models: ExternalModelOption[] }
>();
for (const model of externalModels) {
const searchText = normalizeForSearch(
`${model.name} ${model.providerName} ${model.id}`,
);
if (needle && !searchText.includes(needle)) continue;
const prev = byProvider.get(model.providerId);
if (prev) {
prev.models.push(model);
} else {
byProvider.set(model.providerId, {
providerName: model.providerName,
models: [model],
});
}
}
return [...byProvider.entries()]
.map(([providerId, group]) => ({
providerId,
providerName: group.providerName,
models: group.models.sort((a, b) => a.name.localeCompare(b.name)),
}))
.sort((a, b) => a.providerName.localeCompare(b.providerName));
}, [externalModels, query]);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search external models"
className="h-9 pl-8"
/>
</div>
<div className="max-h-64 overflow-y-auto">
<div className="space-y-2 p-1">
{grouped.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No external models configured.
</div>
) : (
grouped.map((group) => (
<div key={group.providerId}>
<div className="flex items-center gap-2 px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<ExternalProviderLogo
providerType={group.models[0]?.providerType}
className="size-3.5"
title={group.providerName}
/>
<span className="min-w-0 truncate">{group.providerName}</span>
</div>
{group.models.map((model) => (
<button
key={model.id}
type="button"
onClick={() =>
onSelect(model.id, {
source: "external",
isLora: false,
})
}
className={cn(
"flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
value === model.id && "bg-accent/60",
)}
>
<span className="min-w-0 truncate">{model.name}</span>
</button>
))}
</div>
))
)}
</div>
</div>
</div>
);
}

View file

@ -18,8 +18,15 @@ export interface LoraModelOption extends ModelOption {
exportType?: "lora" | "merged" | "gguf";
}
export interface ExternalModelOption extends ModelOption {
providerId: string;
providerName: string;
/** Registry key (e.g. openai, gemini) for provider branding. */
providerType: string;
}
export interface ModelSelectorChangeMeta {
source: "hub" | "lora" | "exported" | "local";
source: "hub" | "lora" | "exported" | "local" | "external";
isLora: boolean;
ggufVariant?: string;
isDownloaded?: boolean;

View file

@ -19,10 +19,8 @@ import {
useAuiState,
} from "@assistant-ui/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Idea01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type VariantProps, cva } from "class-variance-authority";
import { ChevronDownIcon, CopyIcon, CheckIcon } from "lucide-react";
import { CheckIcon, ChevronDownIcon, CopyIcon, LightbulbIcon } from "lucide-react";
import {
type CSSProperties,
type ComponentProps,
@ -128,10 +126,7 @@ function ReasoningTrigger({
)}
{...props}
>
<HugeiconsIcon
icon={Idea01Icon}
className="aui-reasoning-trigger-icon size-4 shrink-0"
/>
<LightbulbIcon className="aui-reasoning-trigger-icon size-4 shrink-0" />
<span
data-slot="reasoning-trigger-label"
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"

View file

@ -31,6 +31,9 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { parseExternalModelId } from "@/features/chat/external-providers";
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
import { isTauri } from "@/lib/api-base";
@ -210,13 +213,28 @@ const ThreadScrollToBottom: FC = () => {
};
const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
const [currentEmoji, setCurrentEmoji] = useState("large sloth drink.png");
useEffect(() => {
const hour = new Date().getHours();
if (hour >= 6 && hour < 12) setCurrentEmoji("large sloth drink.png");
else if (hour >= 12 && hour < 17) setCurrentEmoji("sloth magnify final.png");
else if (hour >= 17 && hour < 21) setCurrentEmoji("sloth shy large.png");
else setCurrentEmoji("unsloth-gem.png");
}, []);
const currentEmojiSrc =
currentEmoji === "unsloth-gem.png"
? `/${currentEmoji}`
: `/Sloth emojis/${currentEmoji}`;
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center pb-[48px]">
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-6 px-4">
<div className="flex flex-col items-center gap-2 text-center">
<img
src="/Sloth emojis/sloth pc square.png"
src={currentEmojiSrc}
alt="Sloth mascot"
className="size-20"
/>
@ -459,15 +477,69 @@ const ReasoningToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const disabled = !(modelLoaded && supportsReasoning);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
const externalProviders = useExternalProvidersStore((s) => s.providers);
const externalSelection = parseExternalModelId(checkpoint);
const selectedExternalProvider =
externalSelection != null
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
const effectiveExternalModelId =
selectedExternalProvider?.providerType === "openrouter" &&
externalSelection?.modelId === "openrouter/free" &&
lastOpenRouterChosenModel
? lastOpenRouterChosenModel
: externalSelection?.modelId;
const externalReasoningCaps =
externalSelection != null
? getExternalReasoningCapabilities(
selectedExternalProvider?.providerType,
effectiveExternalModelId,
)
: null;
const effectiveReasoningStyle =
externalReasoningCaps?.reasoningStyle ?? reasoningStyle;
const effectiveReasoningAlwaysOn =
externalReasoningCaps?.reasoningAlwaysOn ?? reasoningAlwaysOn;
const effectiveSupportsReasoningOff =
externalReasoningCaps?.supportsReasoningOff ?? supportsReasoningOff;
const effectiveReasoningEffortLevels =
externalReasoningCaps?.reasoningEffortLevels ?? reasoningEffortLevels;
const effectiveSupportsReasoning =
externalReasoningCaps?.supportsReasoning ?? supportsReasoning;
const reasoningLockedOn =
effectiveSupportsReasoning &&
(effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff);
const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled;
const effectiveReasoningVisualEnabled =
effectiveReasoningEnabled && reasoningEffort !== "none";
const disabled = !(modelLoaded && effectiveSupportsReasoning);
const formatEffortLabel = (level: typeof reasoningEffort): string => {
if (level !== "xhigh") return level.charAt(0).toUpperCase() + level.slice(1);
const normalized = externalSelection?.modelId?.trim().toLowerCase() ?? "";
if (
normalized.startsWith("claude-opus-4-6") ||
normalized.startsWith("claude-sonnet-4-6")
) {
return "Max";
}
return "Extra High";
};
const effortLabel = formatEffortLabel(reasoningEffort);
if (reasoningStyle === "reasoning_effort") {
if (effectiveReasoningStyle === "reasoning_effort") {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
@ -478,26 +550,47 @@ const ReasoningToggle: FC = () => {
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
disabled
? "cursor-not-allowed opacity-40"
: "bg-primary/10 text-primary hover:bg-primary/20",
: effectiveReasoningVisualEnabled
? "bg-primary/10 text-primary hover:bg-primary/20"
: "text-muted-foreground hover:bg-muted-foreground/15",
)}
aria-label={`Reasoning effort: ${reasoningEffort}`}
>
<LightbulbIcon className="size-3.5" />
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{reasoningEffort.charAt(0).toUpperCase() +
reasoningEffort.slice(1)}
Think: {effectiveReasoningVisualEnabled ? effortLabel : "None"}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(["low", "medium", "high"] as const).map((level) => (
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
None
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => setReasoningEffort(level)}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
}}
>
{level.charAt(0).toUpperCase() + level.slice(1)}
{reasoningEffort === level ? " \u2713" : ""}
{formatEffortLabel(level)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@ -508,17 +601,34 @@ const ReasoningToggle: FC = () => {
return (
<button
type="button"
disabled={disabled}
disabled={disabled || reasoningLockedOn}
aria-disabled={disabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
}}
className="composer-pill-btn"
data-active={reasoningEnabled && !disabled ? "true" : "false"}
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
data-active={
reasoningLockedOn || (effectiveReasoningEnabled && !disabled)
? "true"
: "false"
}
aria-label={
reasoningLockedOn
? "Thinking is required for this model"
: effectiveReasoningEnabled
? "Disable thinking"
: "Enable thinking"
}
>
{reasoningEnabled && !disabled ? (
{reasoningLockedOn || (effectiveReasoningEnabled && !disabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />

View file

@ -2,7 +2,12 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import type { RetainedUpdateFailure, UpdateInfo, UpdateStatus } from "@/hooks/use-tauri-update";
import type {
DesktopUpdatePolicyMode,
RetainedUpdateFailure,
UpdateInfo,
UpdateStatus,
} from "@/hooks/use-tauri-update";
import type { CopySupportDiagnosticsResult } from "@/lib/tauri-diagnostics";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
@ -13,6 +18,8 @@ interface UpdateBannerProps {
dismissed: boolean;
lastFailure: RetainedUpdateFailure | null;
isExternalServer?: boolean;
updatePolicyMode: DesktopUpdatePolicyMode;
manualReleaseUrl: string | null;
onInstall: () => void;
onDismiss: () => void;
onCopyDiagnostics: () => Promise<CopySupportDiagnosticsResult>;
@ -26,6 +33,8 @@ export function UpdateBanner({
dismissed,
lastFailure,
isExternalServer = false,
updatePolicyMode,
manualReleaseUrl,
onInstall,
onDismiss,
onCopyDiagnostics,
@ -36,6 +45,10 @@ export function UpdateBanner({
const showFailure = Boolean(lastFailure) && !dismissed;
const showAvailable = status === "available" && !dismissed && !showFailure;
const show = showFailure || (showAvailable && Boolean(info));
const isManualLinuxPackage = updatePolicyMode === "manual_linux_package";
const installDisabled = isManualLinuxPackage
? manualReleaseUrl === null
: isExternalServer;
async function handleCopyDiagnostics() {
setCopying(true);
@ -67,18 +80,16 @@ export function UpdateBanner({
className="fixed top-4 right-4 z-[9999] w-[380px]"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
{/* Close button */}
<button
type="button"
onClick={onDismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3L3 11M3 3l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{/* Header */}
<div className="flex items-center gap-2">
<span className="text-lg">🦥</span>
<div>
@ -88,37 +99,39 @@ export function UpdateBanner({
<p className="text-xs text-muted-foreground">
{showFailure
? "Backend recovered. Diagnostics are still available."
: isExternalServer
? "Run `unsloth studio update` from your terminal"
: "A new app update is available"}
: isManualLinuxPackage
? "Open the GitHub release page to install the Linux package"
: isExternalServer
? "Run `unsloth studio update` from your terminal"
: "A new app update is available"}
</p>
</div>
</div>
{/* Retained failure */}
{showFailure && lastFailure && (
<p className="mt-3 line-clamp-2 text-xs text-destructive">
{lastFailure.error}
</p>
)}
{/* Actions */}
<div className="mt-3 flex items-center gap-2">
{showFailure ? (
<>
<Button size="sm" variant="outline" className="corner-squircle" onClick={() => void handleCopyDiagnostics()}>
<Button size="sm" variant="outline" className="corner-squircle" onClick={() => {
handleCopyDiagnostics().catch(console.error);
}}>
{copying ? "Copying..." : "Copy Diagnostics"}
</Button>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={isExternalServer}>
Retry Update
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Retry Update"}
</Button>
</>
) : (
<>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={isExternalServer}>
Update Now
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Update Now"}
</Button>
<Button size="sm" variant="outline" className="corner-squircle" disabled>
<Button size="sm" variant="outline" className="corner-squircle" disabled={true}>
Release Notes
</Button>
</>
@ -132,7 +145,7 @@ export function UpdateBanner({
)}
{manualReport && (
<textarea
readOnly
readOnly={true}
value={manualReport}
onFocus={(event) => event.currentTarget.select()}
className="mt-2 h-28 w-full resize-none rounded-lg border border-border/50 bg-muted/30 p-2 font-mono text-[10px] text-muted-foreground"

View file

@ -0,0 +1,136 @@
// 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 { Button } from "@/components/ui/button";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement, useEffect, useRef, useState } from "react";
const STUDIO_UPDATE_CMD = "unsloth studio update";
const RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
interface WebUpdateBannerProps {
enabled?: boolean;
}
export function WebUpdateBanner({
enabled = true,
}: WebUpdateBannerProps): ReactElement | null {
const { status, dismiss } = useWebUpdateCheck({ enabled });
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
};
}, []);
if (isTauri) {
return null;
}
async function handleCopyCommand() {
if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
return;
}
setCopiedVersion(status?.latestVersion ?? null);
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
dismissTimerRef.current = setTimeout(() => dismiss(), 900);
}
return (
<AnimatePresence>
{status ? (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className="fixed top-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[380px]"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
<button
type="button"
onClick={dismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss update notification"
>
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3L3 11M3 3l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
<div className="flex items-start gap-2 pr-5">
<span className="text-lg" aria-hidden="true">
🦥
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
Package update available: {status.latestVersion}
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Installed package: {status.currentVersion}. To update Studio,
run this in your terminal, then restart Studio.
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
size="sm"
className="corner-squircle"
onClick={handleCopyCommand}
>
{copiedVersion === status.latestVersion
? "Copied"
: "Copy command"}
</Button>
<Button
size="sm"
variant="outline"
className="corner-squircle"
asChild={true}
>
<a
href={RELEASE_NOTES_URL}
target="_blank"
rel="noopener noreferrer"
>
Release notes
</a>
</Button>
<Button
size="sm"
variant="ghost"
className="corner-squircle"
onClick={dismiss}
>
Later
</Button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}

View file

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

View file

@ -15,7 +15,27 @@ import {
streamChatCompletions,
validateModel,
} from "./chat-api";
import {
encryptProviderApiKey,
isProviderKeyRotationError,
} from "./providers-api";
import { db } from "../db";
import type {
OpenAIChatCompletionsRequest,
OpenAIMessageContent,
} from "../types/api";
import {
getExternalProviderApiKey,
loadExternalProviders,
parseExternalModelId,
} from "../external-providers";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
clampReasoningEffortToLevels,
getExternalMinOutputTokens,
getExternalReasoningCapabilities,
getProviderCapabilities,
} from "../provider-capabilities";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { isMultimodalResponse } from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
@ -118,6 +138,70 @@ function estimateTokenCount(text: string): number | undefined {
return Math.max(1, Math.round(trimmed.length / 4));
}
/**
* Normalize a streamed `delta.content` to a plain text string.
*
* OpenAI Chat Completions originally typed `delta.content` as a string, but
* a number of providers now emit it as an array of structured content parts.
* Concatenating that with `cumulativeText += delta` would stringify each
* part as `[object Object]` this function is the guard against that.
*
* Handled part shapes:
* { type: "text" | "output_text", text | content: "..." } text body
* { type: "thinking" | "reasoning", thinking | text: "..." } wrapped as
* inline `<think>...</think>` so the downstream parser
* (`parseAssistantContent`) lifts it into a reasoning part the same way
* it does for providers that emit thinking inline. Without this wrap,
* Mistral magistral and similar reasoning-part providers would lose
* their thinking panel.
*
* Unknown part types are skipped better to drop a stray field than to
* stringify an object and pollute the rendered chat with `[object Object]`.
*/
function extractDeltaText(delta: unknown): string {
const extractReasoningText = (payload: unknown): string => {
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) {
return payload.map((item) => extractReasoningText(item)).join("");
}
if (!payload || typeof payload !== "object") return "";
const obj = payload as Record<string, unknown>;
for (const key of ["thinking", "text", "content", "reasoning", "summary"]) {
if (key in obj) {
const text = extractReasoningText(obj[key]);
if (text) return text;
}
}
return "";
};
if (typeof delta === "string") return delta;
if (!Array.isArray(delta)) return "";
let out = "";
for (const part of delta) {
if (typeof part === "string") {
out += part;
continue;
}
if (!part || typeof part !== "object") continue;
const obj = part as {
type?: string;
text?: string;
content?: string;
thinking?: string;
};
if (obj.type === "text" || obj.type === "output_text") {
if (typeof obj.text === "string") out += obj.text;
else if (typeof obj.content === "string") out += obj.content;
} else if (obj.type === "thinking" || obj.type === "reasoning") {
const thinking = extractReasoningText(obj);
if (thinking) out += `<think>${thinking}</think>`;
}
}
return out;
}
function buildTiming(
streamStartTime: number,
totalChunks: number,
@ -162,9 +246,51 @@ function collectTextParts(message: RunMessage): string[] {
return textParts;
}
function collectImageParts(
message: RunMessage,
): Array<{ type: "image_url"; image_url: { url: string } }> {
const parts: Array<{ type: "image_url"; image_url: { url: string } }> = [];
for (const part of message.content ?? []) {
if (part.type === "image" && "image" in part) {
const src = (part as { image: string }).image;
if (src) {
parts.push({
type: "image_url",
image_url: {
url: src.startsWith("data:") ? src : `data:image/png;base64,${src}`,
},
});
}
}
}
if ("attachments" in message && (message.attachments?.length ?? 0) > 0) {
for (const attachment of message.attachments ?? []) {
for (const part of attachment.content ?? []) {
if (part.type === "image" && "image" in part) {
const src = (part as { image: string }).image;
if (src) {
parts.push({
type: "image_url",
image_url: {
url: src.startsWith("data:")
? src
: `data:image/png;base64,${src}`,
},
});
}
}
}
}
}
return parts;
}
function toOpenAIMessage(message: RunMessage): {
role: "system" | "user" | "assistant";
content: string;
content: OpenAIMessageContent;
} | null {
if (
message.role !== "system" &&
@ -174,17 +300,25 @@ function toOpenAIMessage(message: RunMessage): {
return null;
}
let content = collectTextParts(message).join("\n");
let textContent = collectTextParts(message).join("\n");
// Strip inline audio base64 from prior assistant messages to avoid
// inflating token counts (e.g. audio-player responses with embedded WAV).
if (message.role === "assistant") {
content = content.replace(
textContent = textContent.replace(
/data:audio\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g,
"[audio]",
);
}
return { role: message.role, content };
const imageParts = collectImageParts(message);
if (imageParts.length > 0) {
return {
role: message.role,
content: [{ type: "text", text: textContent }, ...imageParts],
};
}
return { role: message.role, content: textContent };
}
function extractImageBase64(input: string): string | undefined {
@ -594,6 +728,29 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
toolsEnabled,
codeToolsEnabled,
} = runtime;
const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null;
const externalProvider = isExternalRequest
? loadExternalProviders().find(
(provider) => provider.id === externalSelection.providerId,
)
: null;
const externalApiKey = externalProvider
? getExternalProviderApiKey(externalProvider.id).trim()
: "";
if (isExternalRequest && !externalProvider) {
toast.error("External provider not found.", {
description: "Open API Providers and re-add this provider.",
});
throw new Error("External provider not found.");
}
if (isExternalRequest && !externalApiKey) {
toast.error("Missing API key for selected external provider.", {
description: "Open API Providers and set the API key again.",
});
throw new Error("Missing external provider API key.");
}
const outboundMessages = messages
.map(toOpenAIMessage)
@ -711,6 +868,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let cumulativeText = "";
let reasoningStartAt: number | null = null;
let reasoningDuration = 0;
// Tracks whether we are currently inside a `<think>` block opened by
// a `delta.reasoning_content` chunk. Kimi (kimi-k2.6, kimi-k2-thinking)
// and DeepSeek's reasoner stream their thinking as a separate
// `reasoning_content` field on the chat-completion delta — not as
// `content`, not as a structured part. We wrap those chunks with
// inline `<think>...</think>` so the existing parseAssistantContent
// lifts them into the reasoning panel the same way it does for
// local Harmony models. State has to live outside the SSE loop
// because the close tag fires when the next chunk carries content
// (or when the stream ends).
let reasoningContentOpen = false;
// Tool call content parts — accumulated and yielded cumulatively.
// result is set directly on the tool-call part when tool_end arrives.
const toolCallParts: ToolCallMessagePart[] = [];
@ -760,8 +928,106 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
supportsPreserveThinking,
preserveThinking,
} = runtime;
const stream = streamChatCompletions(
{
const externalBackendProviderType =
externalProvider?.providerType === "custom"
? "openai"
: externalProvider?.providerType;
const externalCapabilities = getProviderCapabilities(
externalProvider?.providerType,
);
const externalReasoningCaps: ReturnType<
typeof getExternalReasoningCapabilities
> =
externalSelection && externalProvider
? getExternalReasoningCapabilities(
externalProvider.providerType,
externalSelection.modelId,
)
: {
supportsReasoning,
reasoningStyle,
reasoningAlwaysOn: false,
supportsReasoningOff: false,
reasoningEffortLevels: ["low", "medium", "high"] as const,
};
type RequestReasoningEffort = Extract<
NonNullable<OpenAIChatCompletionsRequest["reasoning_effort"]>,
"none" | "minimal" | "low" | "medium" | "high" | "max" | "xhigh"
>;
const fallbackExternalEffort =
(externalReasoningCaps.reasoningEffortLevels[0] ??
"low") as RequestReasoningEffort;
const selectedExternalEffort: RequestReasoningEffort =
clampReasoningEffortToLevels(
reasoningEffort,
externalReasoningCaps.reasoningEffortLevels,
) as RequestReasoningEffort;
const localReasoningEffort =
reasoningEffort === "low" || reasoningEffort === "medium" || reasoningEffort === "high"
? reasoningEffort
: "low";
const externalReasoningEnabled =
!externalReasoningCaps.supportsReasoningOff ? true : reasoningEnabled;
const buildRequestPayload = async (
forceRefreshPublicKey = false,
): Promise<OpenAIChatCompletionsRequest> => {
if (externalSelection && externalProvider) {
return {
model: externalSelection.modelId,
messages: outboundMessages,
stream: true,
// Reasoning-class models (OpenAI gpt-5.x / o3) reject temperature
// and top_p; only forward when the active provider supports them.
...(externalCapabilities?.temperature !== false
? { temperature: params.temperature }
: {}),
...(externalCapabilities?.topP !== false
? { top_p: params.topP }
: {}),
// Clamp to the cross-provider output cap so a maxTokens value
// carried over from a local-model session does not blow past
// provider limits (e.g. Claude Opus 400s on >128k). Also
// floor to the provider's documented minimum — Kimi's
// thinking models need >=16k or the response truncates
// before the answer fits alongside reasoning_content.
max_tokens: Math.min(
Math.max(
params.maxTokens,
getExternalMinOutputTokens(externalProvider?.providerType),
),
EXTERNAL_MAX_OUTPUT_TOKENS,
),
// Only forward sampling knobs the provider actually accepts; the
// backend's external-provider proxy is param-permissive and would
// surface a 400 from providers that reject unknown fields (e.g.
// OpenAI rejects top_k, Anthropic/DeepSeek reject presence_penalty).
...(externalCapabilities?.topK ? { top_k: params.topK } : {}),
...(externalCapabilities?.presencePenalty
? { presence_penalty: params.presencePenalty }
: {}),
provider_id: externalProvider.id,
provider_type: externalBackendProviderType,
external_model: externalSelection.modelId,
encrypted_api_key: await encryptProviderApiKey(
externalApiKey,
forceRefreshPublicKey,
),
provider_base_url: externalProvider.baseUrl || null,
...(externalReasoningCaps.supportsReasoning
? externalReasoningCaps.reasoningStyle === "reasoning_effort"
? externalReasoningEnabled
? { reasoning_effort: selectedExternalEffort }
: externalReasoningCaps.supportsReasoningOff
? { reasoning_effort: "none" }
: {
reasoning_effort: fallbackExternalEffort,
}
: { enable_thinking: reasoningEnabled }
: {}),
};
}
return {
model: params.checkpoint,
messages: outboundMessages,
stream: true,
@ -779,7 +1045,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(useAdapter === undefined ? {} : { use_adapter: useAdapter }),
...(supportsReasoning
? reasoningStyle === "reasoning_effort"
? { reasoning_effort: reasoningEffort }
? reasoningEnabled
? { reasoning_effort: localReasoningEffort }
: {}
: { enable_thinking: reasoningEnabled }
: {}),
...(supportsPreserveThinking ? { preserve_thinking: preserveThinking } : {}),
@ -798,116 +1066,234 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
})(),
}
: {}),
},
abortSignal,
);
};
};
for await (const chunk of stream) {
// Handle tool status events
const toolStatusText = (chunk as unknown as { _toolStatus?: string })._toolStatus;
if (toolStatusText !== undefined) {
runtime.setToolStatus(toolStatusText || null);
continue;
}
let retriedWithRefreshedKey = false;
while (true) {
try {
const stream = streamChatCompletions(
await buildRequestPayload(retriedWithRefreshedKey),
abortSignal,
);
// Emit tool-call content parts for assistant-ui.
// On tool_start: add a new tool-call part (renders in "running" state).
// On tool_end: set result on the existing part (transitions to "complete").
const toolEvent = (chunk as unknown as { _toolEvent?: Record<string, unknown> })._toolEvent;
if (toolEvent !== undefined) {
if (toolEvent.type === "tool_start") {
const id = (toolEvent.tool_call_id as string) || `${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ?? {}) as ToolCallMessagePart["args"];
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
} else if (toolEvent.type === "tool_end") {
const id = (toolEvent.tool_call_id as string) ||
toolCallParts[toolCallParts.length - 1]?.toolCallId || "";
const idx = toolCallParts.findIndex((p) => p.toolCallId === id);
if (idx !== -1) {
const rawResult = (toolEvent.result as string) ?? "";
const imgMarker = "\n__IMAGES__:";
const imgIdx = rawResult.lastIndexOf(imgMarker);
let parsedResult: string | { text: string; images: string[]; sessionId: string };
if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
const sessionId = resolvedThreadId || "_default";
try {
const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[];
parsedResult = { text, images, sessionId };
} catch {
parsedResult = rawResult;
for await (const chunk of stream) {
// Handle tool status events
const toolStatusText = (chunk as unknown as { _toolStatus?: string })._toolStatus;
if (toolStatusText !== undefined) {
runtime.setToolStatus(toolStatusText || null);
continue;
}
// Emit tool-call content parts for assistant-ui.
// On tool_start: add a new tool-call part (renders in "running" state).
// On tool_end: set result on the existing part (transitions to "complete").
const toolEvent = (chunk as unknown as { _toolEvent?: Record<string, unknown> })._toolEvent;
if (toolEvent !== undefined) {
if (toolEvent.type === "tool_start") {
const id = (toolEvent.tool_call_id as string) || `${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ?? {}) as ToolCallMessagePart["args"];
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
} else if (toolEvent.type === "tool_end") {
const id = (toolEvent.tool_call_id as string) ||
toolCallParts[toolCallParts.length - 1]?.toolCallId || "";
const idx = toolCallParts.findIndex((p) => p.toolCallId === id);
if (idx !== -1) {
const rawResult = (toolEvent.result as string) ?? "";
const imgMarker = "\n__IMAGES__:";
const imgIdx = rawResult.lastIndexOf(imgMarker);
let parsedResult: string | { text: string; images: string[]; sessionId: string };
if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
const sessionId = resolvedThreadId || "_default";
try {
const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[];
parsedResult = { text, images, sessionId };
} catch {
parsedResult = rawResult;
}
} else {
parsedResult = rawResult;
}
toolCallParts[idx] = { ...toolCallParts[idx], result: parsedResult };
}
} else {
parsedResult = rawResult;
}
toolCallParts[idx] = { ...toolCallParts[idx], result: parsedResult };
// Yield cumulative state so tool UI updates (tools first, text after)
const textParts = parseAssistantContent(cumulativeText);
yield {
content: [...toolCallParts, ...textParts],
metadata: {
timing: buildTiming(streamStartTime, totalChunks, firstTokenTime),
custom: { reasoningDuration },
},
};
continue;
}
// OpenAI-standard usage chunk: choices=[], usage populated
if (chunk.choices?.length === 0 && chunk.usage) {
serverMetadata = {
usage: chunk.usage,
timings: (chunk as Record<string, unknown>).timings as ServerTimings | undefined,
};
continue;
}
totalChunks += 1;
// OpenRouter's free router (openrouter/free) picks a different
// underlying free model per request and reports it in every
// chunk's top-level `model` field. Latch the first non-empty
// value that differs from the requested checkpoint so the
// header chip can render "openrouter/free:<chosen>".
if (
isExternalRequest &&
externalProvider?.providerType === "openrouter" &&
externalSelection?.modelId === "openrouter/free"
) {
const chunkModel = (chunk as { model?: unknown }).model;
if (
typeof chunkModel === "string" &&
chunkModel.length > 0 &&
chunkModel !== externalSelection.modelId
) {
const storeState = useChatRuntimeStore.getState();
if (storeState.lastOpenRouterChosenModel !== chunkModel) {
storeState.setLastOpenRouterChosenModel(chunkModel);
}
}
}
const rawDelta = chunk.choices?.[0]?.delta?.content;
// Providers like Mistral's magistral return delta.content as an
// array of structured parts; normalize to text (with thinking
// parts re-wrapped as inline <think> tags) so the rest of the
// accumulator stays string-based.
const delta = extractDeltaText(rawDelta);
// Kimi (kimi-k2.6, kimi-k2-thinking) and DeepSeek reasoner
// stream thinking via `delta.reasoning_content` as a plain
// string field — separate from `delta.content` which carries
// the answer. Wrap reasoning chunks inline as <think>...
// </think> so parseAssistantContent treats them like any
// other reasoning. The close tag fires when the next chunk
// brings content, or when the stream ends.
const rawReasoning = (
chunk.choices?.[0]?.delta as
| { reasoning_content?: unknown }
| undefined
)?.reasoning_content;
// OpenRouter uses a third reasoning shape: a structured
// `delta.reasoning_details` array of parts (each carrying
// `text`). The router emits this regardless of which
// underlying provider it picked, so we extract here and
// merge into the same <think>...</think> wrap path used
// for Kimi / DeepSeek reasoning_content. See
// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
const rawReasoningDetails = (
chunk.choices?.[0]?.delta as
| { reasoning_details?: unknown }
| undefined
)?.reasoning_details;
const reasoningFromDetails = Array.isArray(rawReasoningDetails)
? rawReasoningDetails
.map((part) => {
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("")
: "";
const reasoning =
(typeof rawReasoning === "string" ? rawReasoning : "") +
reasoningFromDetails;
if (!delta && !reasoning) {
continue;
}
if (waitingFirstChunk) {
waitingFirstChunk = false;
firstTokenTime = Date.now() - streamStartTime;
settleFirstTokenOk();
runtime.setGeneratingStatus(null);
}
if (reasoning) {
if (!reasoningContentOpen) {
cumulativeText += `<think>${reasoning}`;
reasoningContentOpen = true;
} else {
cumulativeText += reasoning;
}
}
if (delta) {
if (reasoningContentOpen) {
cumulativeText += "</think>";
reasoningContentOpen = false;
}
cumulativeText += delta;
}
// Mistral's magistral occasionally emits a trailing
// template-literal artifact (e.g. "${response}") at the end of
// an otherwise complete answer. It is never part of a real
// reply, so strip a trailing `${...}` token from external
// provider streams. The regex anchors to end-of-string and is
// idempotent — fragments mid-stream (e.g. "${re") leave the
// string untouched and only collapse once the closing brace
// arrives. Local-model output is left alone.
if (isExternalRequest) {
cumulativeText = cumulativeText.replace(
/\s*\$\{[^}]*\}\s*$/,
"",
);
}
const parts = parseAssistantContent(cumulativeText);
if (parts.some((part) => part.type === "reasoning") && !reasoningStartAt) {
reasoningStartAt = Date.now();
}
if (hasClosedThinkTag(cumulativeText) && reasoningStartAt && !reasoningDuration) {
reasoningDuration = Math.round((Date.now() - reasoningStartAt) / 1000);
}
if (parts.length > 0 || toolCallParts.length > 0) {
yield {
content: [...toolCallParts, ...parts],
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
},
};
}
}
// Yield cumulative state so tool UI updates (tools first, text after)
const textParts = parseAssistantContent(cumulativeText);
yield {
content: [...toolCallParts, ...textParts],
metadata: {
timing: buildTiming(streamStartTime, totalChunks, firstTokenTime),
custom: { reasoningDuration },
},
};
continue;
}
// OpenAI-standard usage chunk: choices=[], usage populated
if (chunk.choices?.length === 0 && chunk.usage) {
serverMetadata = {
usage: chunk.usage,
timings: (chunk as Record<string, unknown>).timings as ServerTimings | undefined,
};
continue;
}
totalChunks += 1;
const delta = chunk.choices?.[0]?.delta?.content;
if (!delta) {
continue;
}
if (waitingFirstChunk) {
waitingFirstChunk = false;
firstTokenTime = Date.now() - streamStartTime;
settleFirstTokenOk();
runtime.setGeneratingStatus(null);
}
cumulativeText += delta;
const parts = parseAssistantContent(cumulativeText);
if (parts.some((part) => part.type === "reasoning") && !reasoningStartAt) {
reasoningStartAt = Date.now();
}
if (hasClosedThinkTag(cumulativeText) && reasoningStartAt && !reasoningDuration) {
reasoningDuration = Math.round((Date.now() - reasoningStartAt) / 1000);
}
if (parts.length > 0 || toolCallParts.length > 0) {
yield {
content: [...toolCallParts, ...parts],
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
},
};
break;
} catch (streamError) {
if (
isExternalRequest &&
!retriedWithRefreshedKey &&
isProviderKeyRotationError(streamError)
) {
retriedWithRefreshedKey = true;
continue;
}
throw streamError;
}
}
// If the stream ended while we were still inside a
// delta.reasoning_content block (Kimi / DeepSeek path), close
// the open <think> tag so the reasoning panel parses cleanly.
if (reasoningContentOpen) {
cumulativeText += "</think>";
reasoningContentOpen = false;
}
settleFirstTokenOk();
// Extract source parts from completed web_search tool calls

View file

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

View file

@ -3,6 +3,7 @@
import {
type DeletedModelRef,
type ExternalModelOption,
type LoraModelOption,
type ModelOption,
ModelSelector,
@ -40,6 +41,16 @@ import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
import { ModelLoadInlineStatus } from "./components/model-load-status";
import { db } from "./db";
import {
buildExternalModelId,
isExternalModelId,
parseExternalModelId,
} from "./external-providers";
import {
clampReasoningEffortToLevels,
getExternalReasoningCapabilities,
getProviderCapabilities,
} from "./provider-capabilities";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
clearTrainingCompareHandoff,
@ -54,6 +65,7 @@ import {
SharedComposer,
} from "./shared-composer";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour";
import type { ChatView, MessageRecord } from "./types";
@ -536,6 +548,7 @@ export function ChatPage(): ReactElement {
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
const externalProviders = useExternalProvidersStore((s) => s.providers);
useEffect(() => {
const threadId = search.thread;
@ -596,7 +609,9 @@ export function ChatPage(): ReactElement {
loadProgress,
loadToastDismissed,
} = useChatModelRuntime();
const pendingNativeModelIntent = useNativeIntentStore((state) => state.pendingModelIntent);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
);
const nativePathLeasesSupported = useNativePathLeasesSupported();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@ -605,9 +620,85 @@ export function ChatPage(): ReactElement {
refreshRef.current = refresh;
selectModelRef.current = selectModel;
}, [refresh, selectModel]);
const isExternalModel = useMemo(
() => isExternalModelId(inferenceParams.checkpoint),
[inferenceParams.checkpoint],
);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const activeExternalProviderType = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
const provider = externalProviders.find(
(p) => p.id === selection.providerId,
);
return provider?.providerType ?? null;
}, [externalProviders, inferenceParams.checkpoint]);
const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
const provider = externalProviders.find(
(p) => p.id === selection.providerId,
);
const baseCapabilities = getProviderCapabilities(provider?.providerType);
if (!baseCapabilities) return baseCapabilities;
const anthropicThinkingEnabled =
provider?.providerType === "anthropic" &&
reasoningStyle === "reasoning_effort" &&
(supportsReasoningOff ? reasoningEnabled : true) &&
reasoningEffort !== "none";
if (!anthropicThinkingEnabled) return baseCapabilities;
return {
...baseCapabilities,
temperature: false,
topK: false,
};
}, [
externalProviders,
inferenceParams.checkpoint,
reasoningEnabled,
reasoningStyle,
reasoningEffort,
supportsReasoningOff,
]);
useEffect(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return;
const provider = externalProviders.find((p) => p.id === selection.providerId);
const reasoningCaps = getExternalReasoningCapabilities(
provider?.providerType,
selection.modelId,
);
const state = useChatRuntimeStore.getState();
const preferredEffort = state.reasoningEffort;
const effortLevels = reasoningCaps.reasoningEffortLevels;
const clampedEffort = clampReasoningEffortToLevels(
preferredEffort,
effortLevels,
);
const nextReasoningEffort = reasoningCaps.supportsReasoning
? clampedEffort
: state.reasoningEffort;
useChatRuntimeStore.setState({
supportsReasoning: reasoningCaps.supportsReasoning,
reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn,
reasoningStyle: reasoningCaps.reasoningStyle,
supportsReasoningOff: reasoningCaps.supportsReasoningOff,
reasoningEffortLevels: effortLevels,
reasoningEffort: nextReasoningEffort,
reasoningEnabled: reasoningCaps.supportsReasoning
? reasoningCaps.supportsReasoningOff
? state.reasoningEnabled
: true
: state.reasoningEnabled,
supportsPreserveThinking: false,
});
}, [externalProviders, inferenceParams.checkpoint]);
const canCompare = useMemo(() => {
return Boolean(inferenceParams.checkpoint);
}, [inferenceParams.checkpoint]);
return Boolean(inferenceParams.checkpoint) && !isExternalModel;
}, [inferenceParams.checkpoint, isExternalModel]);
// Derive view from URL search params
const view = useMemo<ChatView>(() => {
@ -632,7 +723,8 @@ export function ChatPage(): ReactElement {
const hasActiveModel = Boolean(inferenceParams.checkpoint);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
const label = intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
const label =
intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
await selectModel({
id: label,
nativePathToken: intent.path.token,
@ -687,6 +779,7 @@ export function ChatPage(): ReactElement {
(
value: string,
meta?: {
source?: string;
isLora: boolean;
ggufVariant?: string;
isDownloaded?: boolean;
@ -702,6 +795,58 @@ export function ChatPage(): ReactElement {
(meta?.ggufVariant ?? null) === (currentVariant ?? null))
)
return;
if (meta?.source === "external" || isExternalModelId(value)) {
const selectedExternal = parseExternalModelId(value);
const selectedProvider = selectedExternal
? externalProviders.find((p) => p.id === selectedExternal.providerId)
: null;
const reasoningCaps = getExternalReasoningCapabilities(
selectedProvider?.providerType,
selectedExternal?.modelId,
);
const preferredEffort = store.reasoningEffort;
const effortLevels = reasoningCaps.reasoningEffortLevels;
const clampedEffort = clampReasoningEffortToLevels(
preferredEffort,
effortLevels,
);
const nextReasoningEffort = reasoningCaps.supportsReasoning
? clampedEffort
: store.reasoningEffort;
// Clear any cached router-picked openrouter/free model unless the
// user is staying on openrouter/free — otherwise the chip would
// keep showing a stale ":<chosen>" suffix from a previous model.
const stillOnOpenRouterFree =
selectedProvider?.providerType === "openrouter" &&
selectedExternal?.modelId === "openrouter/free";
setInferenceParams({
...store.params,
checkpoint: value,
});
useChatRuntimeStore.setState({
activeGgufVariant: null,
ggufContextLength: null,
ggufMaxContextLength: null,
ggufNativeContextLength: null,
activeNativePathToken: null,
supportsReasoning: reasoningCaps.supportsReasoning,
reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn,
reasoningStyle: reasoningCaps.reasoningStyle,
supportsReasoningOff: reasoningCaps.supportsReasoningOff,
reasoningEffortLevels: effortLevels,
reasoningEffort: nextReasoningEffort,
reasoningEnabled: reasoningCaps.supportsReasoning
? reasoningCaps.supportsReasoningOff
? store.reasoningEnabled
: true
: store.reasoningEnabled,
supportsPreserveThinking: false,
...(stillOnOpenRouterFree ? {} : { lastOpenRouterChosenModel: null }),
});
return;
}
// Local model picked → drop any cached openrouter/free chosen model.
useChatRuntimeStore.setState({ lastOpenRouterChosenModel: null });
void (async () => {
let showImageCompatibilityWarning = false;
if (view.mode === "single" && activeThreadId) {
@ -738,7 +883,14 @@ export function ChatPage(): ReactElement {
});
})();
},
[activeThreadId, modelsFromStore, selectModel, view],
[
activeThreadId,
externalProviders,
modelsFromStore,
selectModel,
setInferenceParams,
view,
],
);
const handleEject = useCallback(() => {
void ejectModel();
@ -813,6 +965,47 @@ export function ChatPage(): ReactElement {
})),
[modelsFromStore],
);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
const externalModels = useMemo<ExternalModelOption[]>(
() =>
externalProviders.flatMap((provider) =>
provider.models.map((model) => {
// For OpenRouter's free router we know which underlying free
// model the gateway actually picked once a stream completes
// (chat-adapter latches `chunk.model` into the runtime store).
// Render the chip as `openrouter:<short-chosen>` — drop the
// redundant `/free` from the router id and the org prefix
// from the chosen id (e.g.
// openrouter/free + inclusionai/ring-2.6-1t-20260508:free
// -> openrouter:ring-2.6-1t-20260508:free
// ). The `:free` suffix on the chosen id already conveys
// 'free model', so the leading `/free` is noise.
let displayName = model;
if (
provider.providerType === "openrouter" &&
model === "openrouter/free" &&
lastOpenRouterChosenModel
) {
const lastSlash = lastOpenRouterChosenModel.lastIndexOf("/");
const shortChosen =
lastSlash >= 0
? lastOpenRouterChosenModel.slice(lastSlash + 1)
: lastOpenRouterChosenModel;
displayName = `openrouter:${shortChosen}`;
}
return {
id: buildExternalModelId(provider.id, model),
name: displayName,
providerId: provider.id,
providerName: provider.name,
providerType: provider.providerType,
};
}),
),
[externalProviders, lastOpenRouterChosenModel],
);
const [localModels, setLocalModels] = useState<LoraModelOption[]>([]);
@ -847,20 +1040,24 @@ export function ChatPage(): ReactElement {
.catch(() => {});
}, [navigate]);
const refreshModelLists = useCallback((deletedModel?: DeletedModelRef) => {
const { checkpoint } = useChatRuntimeStore.getState().params;
const activeGgufVariant = useChatRuntimeStore.getState().activeGgufVariant;
if (
modelMatchesDeleted(
{ id: checkpoint, ggufVariant: activeGgufVariant },
deletedModel,
)
) {
useChatRuntimeStore.getState().clearCheckpoint();
}
void refresh();
refreshLocalModels();
}, [refresh, refreshLocalModels]);
const refreshModelLists = useCallback(
(deletedModel?: DeletedModelRef) => {
const { checkpoint } = useChatRuntimeStore.getState().params;
const activeGgufVariant =
useChatRuntimeStore.getState().activeGgufVariant;
if (
modelMatchesDeleted(
{ id: checkpoint, ggufVariant: activeGgufVariant },
deletedModel,
)
) {
useChatRuntimeStore.getState().clearCheckpoint();
}
void refresh();
refreshLocalModels();
},
[refresh, refreshLocalModels],
);
const loraModels = useMemo<LoraModelOption[]>(() => {
const fromLoras = lorasFromStore.map((lora) => ({
@ -1001,6 +1198,7 @@ export function ChatPage(): ReactElement {
<ModelSelector
models={models}
loraModels={loraModels}
externalModels={externalModels}
value={inferenceParams.checkpoint}
activeGgufVariant={activeGgufVariant}
onValueChange={handleCheckpointChange}
@ -1014,6 +1212,7 @@ export function ChatPage(): ReactElement {
onOpenChange={handleModelSelectorOpenChange}
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
showCloudIndicator={isExternalModel}
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[34px]"
/>
)}
@ -1120,6 +1319,9 @@ export function ChatPage(): ReactElement {
onOpenChange={setSettingsOpen}
params={inferenceParams}
onParamsChange={setInferenceParams}
isExternalModel={isExternalModel}
providerCapabilities={activeProviderCapabilities}
externalProviderType={activeExternalProviderType}
onReloadModel={() => {
const state = useChatRuntimeStore.getState();
if (state.params.checkpoint) {

File diff suppressed because it is too large Load diff

View file

@ -80,6 +80,11 @@ import {
toPresetParams,
type Preset,
} from "./presets/preset-policy";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
getExternalMinOutputTokens,
type ProviderCapabilities,
} from "./provider-capabilities";
import type { InferenceParams } from "./types/runtime";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
@ -505,6 +510,19 @@ interface ChatSettingsPanelProps {
onOpenChange?: (open: boolean) => void;
params: InferenceParams;
onParamsChange: (params: InferenceParams) => void;
isExternalModel?: boolean;
/**
* Sampling-param capability set for the active external provider, or `null`
* for local models (in which case every knob is rendered). Drives the
* per-param visibility in the sampling section.
*/
providerCapabilities?: ProviderCapabilities | null;
/**
* Backend provider type for the active external model (e.g. "kimi",
* "anthropic", "openai"), or `null` for local models. Drives the
* per-provider Max Tokens floor in the slider.
*/
externalProviderType?: string | null;
onReloadModel?: () => void;
}
@ -513,11 +531,28 @@ export function ChatSettingsPanel({
onOpenChange,
params,
onParamsChange,
isExternalModel = false,
providerCapabilities = null,
externalProviderType = null,
onReloadModel,
}: ChatSettingsPanelProps) {
// For non-external (local) models we show every knob — providerCapabilities
// is only consulted when `isExternalModel` is true. An external model with an
// unknown provider falls back to the OpenAI-compat shape via
// getProviderCapabilities, so these flags never undercount support.
const showTemperature =
!isExternalModel || Boolean(providerCapabilities?.temperature);
const showTopP = !isExternalModel || Boolean(providerCapabilities?.topP);
const showTopK = !isExternalModel || Boolean(providerCapabilities?.topK);
const showMinP = !isExternalModel || Boolean(providerCapabilities?.minP);
const showRepetitionPenalty =
!isExternalModel || Boolean(providerCapabilities?.repetitionPenalty);
const showPresencePenalty =
!isExternalModel || Boolean(providerCapabilities?.presencePenalty);
const isMobile = useIsMobile();
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const hasModelContent = isGguf || Boolean(params.checkpoint);
const hasModelContent =
!isExternalModel && (isGguf || Boolean(params.checkpoint));
const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType);
const loadedSpeculativeType = useChatRuntimeStore(
@ -1131,65 +1166,79 @@ export function ChatSettingsPanel({
<CollapsibleSection label="Sampling" defaultOpen={true}>
<div className="flex flex-col gap-5 pt-1">
<ParamSlider
label="Temperature"
value={params.temperature}
min={0}
max={2}
step={0.01}
onChange={set("temperature")}
info="Controls randomness. Lower values make output focused and deterministic; higher values increase variety and creativity."
/>
<ParamSlider
label="Top P"
value={params.topP}
min={0}
max={1}
step={0.05}
onChange={set("topP")}
displayValue={params.topP === 1 ? "Off" : undefined}
info="Nucleus sampling. Restricts choices to the smallest set of tokens whose cumulative probability reaches this threshold. 1.0 = off."
/>
<ParamSlider
label="Top K"
value={params.topK}
min={0}
max={100}
step={1}
onChange={set("topK")}
displayValue={params.topK === 0 ? "Off" : undefined}
info="Limits sampling to the K most likely tokens at each step. 0 = off."
/>
<ParamSlider
label="Min P"
value={params.minP}
min={0}
max={1}
step={0.01}
onChange={set("minP")}
info="Drops tokens whose probability is below this fraction of the top token's probability. Filters unlikely candidates."
/>
<ParamSlider
label="Repetition Penalty"
value={params.repetitionPenalty}
min={1}
max={2}
step={0.05}
onChange={set("repetitionPenalty")}
displayValue={params.repetitionPenalty === 1 ? "Off" : undefined}
info="Down-weights tokens that have already appeared, reducing repetition. 1.0 = off; higher values penalize more strongly."
/>
<ParamSlider
label="Presence Penalty"
value={params.presencePenalty}
min={0}
max={2}
step={0.1}
onChange={set("presencePenalty")}
displayValue={params.presencePenalty === 0 ? "Off" : undefined}
info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off."
/>
{!isGguf && (
{showTemperature ? (
<ParamSlider
label="Temperature"
value={params.temperature}
min={0}
max={2}
step={0.01}
onChange={set("temperature")}
info="Controls randomness. Lower values make output focused and deterministic; higher values increase variety and creativity."
/>
) : null}
{showTopP ? (
<ParamSlider
label="Top P"
value={params.topP}
min={0}
max={1}
step={0.05}
onChange={set("topP")}
displayValue={params.topP === 1 ? "Off" : undefined}
info="Nucleus sampling. Restricts choices to the smallest set of tokens whose cumulative probability reaches this threshold. 1.0 = off."
/>
) : null}
{showTopK ? (
<ParamSlider
label="Top K"
value={params.topK}
min={0}
max={100}
step={1}
onChange={set("topK")}
displayValue={params.topK === 0 ? "Off" : undefined}
info="Limits sampling to the K most likely tokens at each step. 0 = off."
/>
) : null}
{showMinP ? (
<ParamSlider
label="Min P"
value={params.minP}
min={0}
max={1}
step={0.01}
onChange={set("minP")}
info="Drops tokens whose probability is below this fraction of the top token's probability. Filters unlikely candidates."
/>
) : null}
{showRepetitionPenalty ? (
<ParamSlider
label="Repetition Penalty"
value={params.repetitionPenalty}
min={1}
max={2}
step={0.05}
onChange={set("repetitionPenalty")}
displayValue={
params.repetitionPenalty === 1 ? "Off" : undefined
}
info="Down-weights tokens that have already appeared, reducing repetition. 1.0 = off; higher values penalize more strongly."
/>
) : null}
{showPresencePenalty ? (
<ParamSlider
label="Presence Penalty"
value={params.presencePenalty}
min={0}
max={2}
step={0.1}
onChange={set("presencePenalty")}
displayValue={params.presencePenalty === 0 ? "Off" : undefined}
info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off."
/>
) : null}
{!isExternalModel && !isGguf && (
<ParamSlider
label="Max Seq Length"
value={params.maxSeqLength}
@ -1203,8 +1252,18 @@ export function ChatSettingsPanel({
<ParamSlider
label="Max Tokens"
value={params.maxTokens}
min={64}
max={isGguf && ggufContextLength ? ggufContextLength : 32768}
min={
isExternalModel
? getExternalMinOutputTokens(externalProviderType)
: 64
}
max={
isExternalModel
? EXTERNAL_MAX_OUTPUT_TOKENS
: isGguf && ggufContextLength
? ggufContextLength
: 32768
}
step={64}
onChange={set("maxTokens")}
displayValue={
@ -1219,13 +1278,15 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection label="Tools">
<div className="flex flex-col gap-5 pt-1">
<AutoHealToolCallsToggle />
<MaxToolCallsSlider />
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>
{!isExternalModel ? (
<CollapsibleSection label="Tools">
<div className="flex flex-col gap-5 pt-1">
<AutoHealToolCallsToggle />
<MaxToolCallsSlider />
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>
) : null}
</div>
</div>
<Dialog

View file

@ -0,0 +1,230 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export interface ExternalProviderConfig {
id: string;
/** Backend provider type (e.g. openai, mistral, gemini). */
providerType: string;
/** Display name in UI. */
name: string;
/** Provider base URL (default from registry or backend-saved override). */
baseUrl: string;
/** Model ids user enabled from `/api/providers/models`. */
models: string[];
/** Cached available model ids from the provider's /models response. */
availableModels?: string[];
createdAt: number;
updatedAt: number;
}
const EXTERNAL_PROVIDERS_KEY = "unsloth_chat_external_providers";
const EXTERNAL_PROVIDER_KEYS_KEY = "unsloth_chat_external_provider_keys";
const EXTERNAL_MODEL_PREFIX = "external::";
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
export function isExternalModelId(
value: string | null | undefined,
): value is string {
return typeof value === "string" && value.startsWith(EXTERNAL_MODEL_PREFIX);
}
export function buildExternalModelId(providerId: string, modelId: string): string {
return `${EXTERNAL_MODEL_PREFIX}${providerId}::${encodeURIComponent(modelId)}`;
}
export function parseExternalModelId(
value: string | null | undefined,
): { providerId: string; modelId: string } | null {
if (!isExternalModelId(value)) return null;
const payload = value.slice(EXTERNAL_MODEL_PREFIX.length);
const separator = payload.indexOf("::");
if (separator < 0) return null;
const providerId = payload.slice(0, separator);
const encodedModelId = payload.slice(separator + 2);
if (!providerId || !encodedModelId) return null;
try {
return { providerId, modelId: decodeURIComponent(encodedModelId) };
} catch {
return null;
}
}
function isExternalProviderConfig(value: unknown): value is ExternalProviderConfig {
if (!value || typeof value !== "object") return false;
const maybe = value as Partial<ExternalProviderConfig>;
return (
typeof maybe.id === "string" &&
typeof maybe.providerType === "string" &&
typeof maybe.name === "string" &&
typeof maybe.baseUrl === "string" &&
Array.isArray(maybe.models)
);
}
function mapLegacyPresetToProviderType(presetId: string): string {
if (presetId === "google") return "gemini";
return presetId;
}
function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig {
return {
...raw,
providerType: raw.providerType.trim(),
name: raw.name.trim(),
baseUrl: raw.baseUrl.trim(),
models: raw.models
.map((model) => model.trim())
.filter((model) => model.length > 0),
availableModels: (raw.availableModels ?? [])
.map((model) => model.trim())
.filter((model) => model.length > 0),
};
}
function isCompleteProvider(provider: ExternalProviderConfig): boolean {
if (!provider.id || !provider.name || !provider.providerType) return false;
return true;
}
type LegacyProviderConfig = {
id?: unknown;
presetId?: unknown;
name?: unknown;
baseUrl?: unknown;
models?: unknown;
createdAt?: unknown;
updatedAt?: unknown;
};
function fromUnknownProvider(value: unknown): ExternalProviderConfig | null {
if (!value || typeof value !== "object") return null;
if (isExternalProviderConfig(value)) {
return value;
}
const legacy = value as LegacyProviderConfig;
const id = typeof legacy.id === "string" ? legacy.id : "";
const presetId = typeof legacy.presetId === "string" ? legacy.presetId : "";
if (!id || !presetId || presetId === "custom") return null;
const providerType = mapLegacyPresetToProviderType(presetId);
if (!providerType) return null;
return {
id,
providerType,
name: typeof legacy.name === "string" ? legacy.name : providerType,
baseUrl: typeof legacy.baseUrl === "string" ? legacy.baseUrl : "",
models: Array.isArray(legacy.models)
? legacy.models.filter((item): item is string => typeof item === "string")
: [],
createdAt: typeof legacy.createdAt === "number" ? legacy.createdAt : Date.now(),
updatedAt: typeof legacy.updatedAt === "number" ? legacy.updatedAt : Date.now(),
};
}
export function loadExternalProviders(): ExternalProviderConfig[] {
if (!canUseStorage()) return [];
try {
const raw = localStorage.getItem(EXTERNAL_PROVIDERS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.map(fromUnknownProvider)
.filter((provider): provider is ExternalProviderConfig => provider !== null)
.map(normalizeProvider)
.filter(isCompleteProvider);
} catch {
return [];
}
}
/**
* Load the raw (encrypted or legacy plaintext) key map from localStorage.
* Values are opaque strings either AES-GCM ciphertext or legacy plaintext.
*/
function loadRawKeyMap(): Record<string, string> {
if (!canUseStorage()) return {};
try {
const raw = localStorage.getItem(EXTERNAL_PROVIDER_KEYS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [providerId, value] of Object.entries(parsed)) {
if (typeof providerId === "string" && typeof value === "string") {
out[providerId] = value;
}
}
return out;
} catch {
return {};
}
}
function saveRawKeyMap(map: Record<string, string>): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(EXTERNAL_PROVIDER_KEYS_KEY, JSON.stringify(map));
} catch {
// ignore
}
}
export function saveExternalProviders(
providers: ExternalProviderConfig[],
): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers));
// Prune keys for removed providers — works on raw ciphertext, no decryption needed
const allowedIds = new Set(providers.map((provider) => provider.id));
const keys = loadRawKeyMap();
const pruned: Record<string, string> = {};
for (const [providerId, value] of Object.entries(keys)) {
if (allowedIds.has(providerId)) {
pruned[providerId] = value;
}
}
saveRawKeyMap(pruned);
} catch {
// ignore
}
}
/**
* Retrieve a provider API key from localStorage.
* Returns "" if no key is stored.
*/
export function getExternalProviderApiKey(
providerId: string,
): string {
const keys = loadRawKeyMap();
return keys[providerId] ?? "";
}
/**
* Store a provider API key in localStorage.
*/
export function setExternalProviderApiKey(
providerId: string,
apiKey: string,
): void {
if (!canUseStorage()) return;
const keys = loadRawKeyMap();
keys[providerId] = apiKey;
saveRawKeyMap(keys);
}
export function removeExternalProviderApiKey(providerId: string): void {
if (!canUseStorage()) return;
try {
const keys = loadRawKeyMap();
delete keys[providerId];
saveRawKeyMap(keys);
} catch {
// ignore
}
}

View file

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

View file

@ -0,0 +1,448 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Per-provider sampling parameter capability matrix.
*
* Values are derived from each provider's published chat-completion docs as of
* 2026-05. They describe which of our UI knobs map cleanly onto the provider's
* request body; the panel hides params a provider does not accept so users
* cannot dial a value that gets silently dropped or rejected.
*
* "Local" models (anything that is not an external provider) are represented by
* a null capability every knob renders for them.
*/
export interface ProviderCapabilities {
/**
* Temperature sampling. Reasoning-class models (OpenAI's gpt-5.x / o3 via
* /v1/responses) reject this with `Unsupported parameter`.
*/
temperature: boolean;
/** Nucleus (top_p) sampling. Same restriction as `temperature` on OpenAI. */
topP: boolean;
/** top-k token sampling (only Anthropic on the providers we ship). */
topK: boolean;
/** min-p token cutoff (no SaaS provider currently exposes this). */
minP: boolean;
/** Repetition penalty (no SaaS provider currently exposes this). */
repetitionPenalty: boolean;
/** OpenAI-style presence penalty. */
presencePenalty: boolean;
}
export type ExternalReasoningCapabilities = {
supportsReasoning: boolean;
reasoningStyle: "enable_thinking" | "reasoning_effort";
reasoningAlwaysOn: boolean;
supportsReasoningOff: boolean;
reasoningEffortLevels: readonly (
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "max"
| "xhigh"
)[];
};
/**
* Prefer a stored reasoning effort level that exists in ``effortLevels``,
* mapping legacy "xhigh" to "max" when the model only exposes the latter
* (Claude 4.6 adaptive thinking).
*/
export function clampReasoningEffortToLevels(
preferred: ExternalReasoningCapabilities["reasoningEffortLevels"][number],
effortLevels: ExternalReasoningCapabilities["reasoningEffortLevels"],
): ExternalReasoningCapabilities["reasoningEffortLevels"][number] {
let candidate = preferred;
if (
candidate === "xhigh" &&
!effortLevels.includes("xhigh") &&
effortLevels.includes("max")
) {
candidate = "max";
}
if (effortLevels.includes(candidate)) {
return candidate;
}
return effortLevels[0] ?? "low";
}
/**
* Output-token cap for any external provider request. Picked to stay below the
* tightest declared limit across the providers we ship (Anthropic Claude Opus
* tops out at 128k, GPT-5.x ~128k, Gemini 2.5 ~65k, DeepSeek 8k) while staying
* well above what a typical chat reply needs. The local-model path is not
* subject to this local backends honour whatever the loaded context allows.
*
* If a user's stored maxTokens (e.g. carried over from a prior local-model
* session with a 128k+ context) exceeds this, chat-adapter clamps the
* outbound request so the provider does not 400 on it.
*/
export const EXTERNAL_MAX_OUTPUT_TOKENS = 32768;
/**
* Per-provider minimum on the outbound max_tokens. Kimi's docs require
* `max_tokens >= 16000` whenever a thinking model is in use so the
* reasoning_content and final answer both fit in the budget anything
* lower truncates the response mid-stream. Other providers don't have a
* documented floor, so they fall through to the generic min of 64 in
* the slider.
*
* The chat-adapter resolves the effective floor on send and bumps the
* outbound max_tokens up to this value if the user's stored maxTokens
* sits below it. The settings panel reflects the same floor as the
* slider min so the displayed value never drifts from what's sent.
*/
const EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER: Record<string, number> = {
kimi: 16000,
};
export function getExternalMinOutputTokens(
providerType: string | null | undefined,
): number {
if (!providerType) return 64;
return EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER[providerType] ?? 64;
}
const OPENAI_COMPAT_BASE: ProviderCapabilities = {
temperature: true,
topP: true,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: true,
};
const ALL_SUPPORTED: ProviderCapabilities = {
temperature: true,
topP: true,
topK: true,
minP: true,
repetitionPenalty: true,
presencePenalty: true,
};
const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
// OpenAI's flagship models (gpt-5.x / o3 / gpt-4.5) are reasoning-class
// models served via /v1/responses, which rejects temperature, top_p, and
// presence/frequency penalty. See backend
// external_provider._stream_openai_responses for the proxy.
openai: {
temperature: false,
topP: false,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
},
// Anthropic's Messages API accepts top_k on 3.x and 4.5/4.6, but Claude
// 4.7 (Opus/Sonnet/Haiku) deprecated it and returns 400 if it is set.
// We surface top_k in the panel for all Anthropic providers and let the
// backend strip it per-model — see _stream_anthropic in
// studio/backend/core/inference/external_provider.py.
// Presence/frequency penalty is not part of the Messages API on any
// Claude generation.
anthropic: {
temperature: true,
topP: true,
topK: true,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
},
mistral: OPENAI_COMPAT_BASE,
gemini: OPENAI_COMPAT_BASE,
// Kimi k2.5/k2.6 are reasoning-class — the API locks temperature and
// top_p to fixed defaults and 400s on any other value:
// "invalid temperature: only 1 is allowed for this model".
// Hide both sliders so the user is not offered knobs the model
// silently overrides. Backend additionally strips these fields via
// PROVIDER_REGISTRY['kimi']['body_omit'].
kimi: {
temperature: false,
topP: false,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: true,
},
// DeepSeek deprecated presence/frequency penalty in their current docs.
deepseek: {
temperature: true,
topP: true,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
},
qwen: OPENAI_COMPAT_BASE,
huggingface: OPENAI_COMPAT_BASE,
// OpenRouter silently drops params the target model does not support, so we
// surface every knob and let the gateway handle the per-model fan-out.
openrouter: ALL_SUPPORTED,
// Custom providers are assumed OpenAI-compatible by the backend; users who
// point at vLLM/Ollama backends often want top_k / min_p / repetition,
// so be permissive.
custom: ALL_SUPPORTED,
};
const DEFAULT_EXTERNAL_CAPABILITIES = OPENAI_COMPAT_BASE;
/**
* Resolve the capability set for an external provider. Returns `null` for
* a local model (i.e. when `providerType` is null/undefined), which callers
* should treat as "every knob applies".
*/
export function getProviderCapabilities(
providerType: string | null | undefined,
): ProviderCapabilities | null {
if (!providerType) return null;
return PROVIDER_CAPABILITIES[providerType] ?? DEFAULT_EXTERNAL_CAPABILITIES;
}
const DEFAULT_EFFORT_LEVELS = ["low", "medium", "high"] as const;
const OPENROUTER_MANDATORY_REASONING_MODELS = new Set([
"google/gemini-pro-latest",
"baidu/cobuddy:free",
"inclusionai/ring-2.6-1t:free",
"deepseek/deepseek-r1",
]);
function isOpenRouterMandatoryReasoningModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
const canonical = normalized.startsWith("~") ? normalized.slice(1) : normalized;
return OPENROUTER_MANDATORY_REASONING_MODELS.has(canonical);
}
type ReasoningCaps = {
supportsReasoning: boolean;
supportsReasoningOff: boolean;
reasoningEffortLevels: ExternalReasoningCapabilities["reasoningEffortLevels"];
};
const DEFAULT_EXTERNAL_REASONING_CAPABILITIES: ExternalReasoningCapabilities = {
supportsReasoning: false,
reasoningStyle: "enable_thinking",
reasoningAlwaysOn: false,
supportsReasoningOff: false,
reasoningEffortLevels: DEFAULT_EFFORT_LEVELS,
};
const NO_REASONING_CAPS: ReasoningCaps = {
supportsReasoning: false,
supportsReasoningOff: false,
reasoningEffortLevels: DEFAULT_EFFORT_LEVELS,
};
const ANTHROPIC_REASONING_MODELS = [
{
prefixes: ["claude-opus-4-7"],
levels: ["none", "low", "medium", "high", "xhigh"],
},
{
prefixes: ["claude-opus-4-6", "claude-sonnet-4-6"],
levels: ["none", "low", "medium", "high", "max"],
},
{
prefixes: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"],
// Backend maps semantic levels to manual budget_tokens.
levels: ["none", "low", "medium", "high"],
},
] as const;
function matchesModelPrefix(
modelId: string,
prefixes: readonly string[],
): boolean {
return prefixes.some((prefix) => modelId.startsWith(prefix));
}
function resolveAnthropicReasoningEffortCapabilities(modelId: string): ReasoningCaps {
const normalized = modelId.trim().toLowerCase();
const matched = ANTHROPIC_REASONING_MODELS.find((entry) =>
matchesModelPrefix(normalized, entry.prefixes),
);
if (matched) {
return {
supportsReasoning: true,
supportsReasoningOff: true,
reasoningEffortLevels: matched.levels,
};
}
return NO_REASONING_CAPS;
}
const OPENAI_REASONING_MODELS = [
{
prefixes: ["gpt-5.5-pro", "gpt-5.4-pro"],
supportsOff: false,
levels: ["medium", "high", "xhigh"],
},
{
prefixes: ["gpt-5.5", "gpt-5.4"],
supportsOff: true,
levels: ["none", "low", "medium", "high", "xhigh"],
},
{
prefixes: ["gpt-5.3-chat-latest"],
supportsOff: false,
levels: ["medium"],
},
{
prefixes: ["gpt-5.3-codex"],
supportsOff: true,
levels: ["none", "low", "medium", "high", "xhigh"],
},
{
prefixes: ["gpt-5", "gpt-5.1", "gpt-5.2"],
supportsOff: false,
levels: ["minimal", "low", "medium", "high"],
},
{
prefixes: ["o3"],
supportsOff: false,
levels: DEFAULT_EFFORT_LEVELS,
},
] as const;
function resolveOpenAIReasoningEffortCapabilities(modelId: string): ReasoningCaps {
const normalized = modelId.trim().toLowerCase();
const matched = OPENAI_REASONING_MODELS.find((entry) =>
matchesModelPrefix(normalized, entry.prefixes),
);
if (matched) {
return {
supportsReasoning: true,
supportsReasoningOff: matched.supportsOff,
reasoningEffortLevels: matched.levels,
};
}
return NO_REASONING_CAPS;
}
function withEnableThinkingStyle(
overrides?: Partial<ExternalReasoningCapabilities>,
): ExternalReasoningCapabilities {
return {
...DEFAULT_EXTERNAL_REASONING_CAPABILITIES,
...overrides,
reasoningStyle: "enable_thinking",
};
}
function withReasoningEffortStyle(caps: ReasoningCaps): ExternalReasoningCapabilities {
return {
...DEFAULT_EXTERNAL_REASONING_CAPABILITIES,
supportsReasoning: true,
reasoningStyle: "reasoning_effort",
supportsReasoningOff: caps.supportsReasoningOff,
reasoningEffortLevels: caps.reasoningEffortLevels,
};
}
function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCapabilities {
// Kimi exposes a boolean thinking toggle rather than an effort scale.
// - kimi-k2.6: thinking enabled by default, toggleable
// via extra_body: {thinking: {type: enabled|disabled}}
// - kimi-k2-thinking: thinking always on, no off switch
// - kimi-k2.5 (and anything else): no thinking
if (modelId === "kimi-k2-thinking") {
return withEnableThinkingStyle({
supportsReasoning: true,
reasoningAlwaysOn: true,
});
}
if (modelId === "kimi-k2.6") {
return withEnableThinkingStyle({
supportsReasoning: true,
supportsReasoningOff: true,
});
}
return withEnableThinkingStyle();
}
function resolveMistralReasoningCapabilities(modelId: string): ExternalReasoningCapabilities {
if (modelId === "magistral-medium-latest") {
return withReasoningEffortStyle({
supportsReasoning: true,
supportsReasoningOff: false,
// Native reasoning model: present baseline as Medium in the UI.
reasoningEffortLevels: ["medium", "high"] as const,
});
}
if (modelId === "mistral-small-latest" || modelId === "mistral-vibe-cli-latest") {
return withReasoningEffortStyle({
supportsReasoning: true,
supportsReasoningOff: true,
reasoningEffortLevels: ["none", "high"] as const,
});
}
return withEnableThinkingStyle();
}
/**
* resolve external-model thinking capabilities.
* provider-specific matching lives in the OpenAI/Anthropic resolvers.
* other providers default to no reasoning controls.
*/
export function getExternalReasoningCapabilities(
providerType: string | null | undefined,
modelId: string | null | undefined,
): ExternalReasoningCapabilities {
const normalizedModel = modelId?.trim().toLowerCase() ?? "";
const normalizedProvider = providerType?.trim().toLowerCase() ?? "";
if (!normalizedModel) {
return withEnableThinkingStyle();
}
// Some OpenRouter-routed ids are mandatory-reasoning and must stay on even
// if they arrive through aliased/custom provider routes.
if (isOpenRouterMandatoryReasoningModel(normalizedModel)) {
return withEnableThinkingStyle({
supportsReasoning: true,
reasoningAlwaysOn: true,
supportsReasoningOff: false,
});
}
// OpenRouter ids are namespaced (e.g. "openai/gpt-5.5").
const modelForMatching =
normalizedProvider === "openrouter" && normalizedModel.includes("/")
? normalizedModel.split("/").at(-1) ?? normalizedModel
: normalizedModel;
const isOpenAIProvider = normalizedProvider === "openai";
const isAnthropicProvider = normalizedProvider === "anthropic";
const isKimiProvider = normalizedProvider === "kimi";
const isMistralProvider = normalizedProvider === "mistral";
const isOpenRouterProvider = normalizedProvider === "openrouter";
if (isOpenRouterProvider) {
// OpenRouter's unified `reasoning` parameter is accepted on every
// chat-completion request; the gateway silently no-ops for models
// that don't reason. Mandatory-reasoning ids are handled by the
// early guard above; everything else exposes a toggleable control.
return {
supportsReasoning: true,
reasoningStyle: "enable_thinking",
reasoningAlwaysOn: false,
supportsReasoningOff: true,
reasoningEffortLevels: DEFAULT_EFFORT_LEVELS,
};
}
if (isKimiProvider) return resolveKimiReasoningCapabilities(modelForMatching);
if (isMistralProvider) return resolveMistralReasoningCapabilities(modelForMatching);
if (!isOpenAIProvider && !isAnthropicProvider) {
return withEnableThinkingStyle();
}
const providerCaps = isOpenAIProvider
? resolveOpenAIReasoningEffortCapabilities(modelForMatching)
: resolveAnthropicReasoningEffortCapabilities(modelForMatching);
if (providerCaps.supportsReasoning) {
return withReasoningEffortStyle(providerCaps);
}
return withEnableThinkingStyle();
}

View file

@ -18,7 +18,13 @@ import { useAui } from "@assistant-ui/react";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { loadModel, validateModel } from "./api/chat-api";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { parseExternalModelId } from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
import { getExternalReasoningCapabilities } from "./provider-capabilities";
import {
type CompositionEvent,
type KeyboardEvent,
@ -66,6 +72,33 @@ function fileToBase64DataURL(file: File): Promise<string> {
});
}
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
if (
normalized.startsWith("claude-opus-4-6") ||
normalized.startsWith("claude-sonnet-4-6")
) {
return "Max";
}
return "Extra High";
}
return level.charAt(0).toUpperCase() + level.slice(1);
}
function formatReasoningDisabledLabel(
supportsReasoningOff: boolean,
isExternalOpenAIReasoning: boolean,
modelId?: string,
): string {
const normalized = modelId?.trim().toLowerCase() ?? "";
// Magistral keeps the "none" wire value, but UX should present this floor
// as "Medium" rather than a disabled state label.
if (normalized.includes("magistral-medium-latest")) return "Medium";
return supportsReasoningOff && isExternalOpenAIReasoning ? "None" : "Off";
}
function useDictation(
setText: (value: string | ((prev: string) => string)) => void,
) {
@ -253,6 +286,8 @@ export function SharedComposer({
const checkpoint = s.params.checkpoint;
return s.models.find((m) => m.id === checkpoint);
});
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const externalProviders = useExternalProvidersStore((s) => s.providers);
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
@ -262,6 +297,8 @@ export function SharedComposer({
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
@ -271,7 +308,49 @@ export function SharedComposer({
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
const reasoningDisabled = !modelLoaded || !supportsReasoning;
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
const externalSelection = parseExternalModelId(checkpoint);
const selectedExternalProvider =
externalSelection != null
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
const effectiveExternalModelId =
selectedExternalProvider?.providerType === "openrouter" &&
externalSelection?.modelId === "openrouter/free" &&
lastOpenRouterChosenModel
? lastOpenRouterChosenModel
: externalSelection?.modelId;
const externalReasoningCaps =
externalSelection != null
? getExternalReasoningCapabilities(
selectedExternalProvider?.providerType,
effectiveExternalModelId,
)
: null;
const isExternalOpenAIReasoning =
externalReasoningCaps?.supportsReasoning === true &&
externalReasoningCaps.reasoningStyle === "reasoning_effort";
const effectiveReasoningStyle =
externalReasoningCaps?.reasoningStyle ?? reasoningStyle;
const effectiveReasoningAlwaysOn =
externalReasoningCaps?.reasoningAlwaysOn ?? reasoningAlwaysOn;
const effectiveSupportsReasoningOff =
externalReasoningCaps?.supportsReasoningOff ?? supportsReasoningOff;
const effectiveReasoningEffortLevels =
externalReasoningCaps?.reasoningEffortLevels ?? reasoningEffortLevels;
const effectiveSupportsReasoning =
externalReasoningCaps?.supportsReasoning ?? supportsReasoning;
const reasoningLockedOn =
effectiveSupportsReasoning &&
(effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff);
const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled;
const effectiveReasoningVisualEnabled =
effectiveReasoningEnabled && reasoningEffort !== "none";
const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning;
const showReasoningControl =
effectiveSupportsReasoning || effectiveReasoningAlwaysOn;
const toolsDisabled = !modelLoaded || !supportsTools;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
@ -625,7 +704,8 @@ export function SharedComposer({
</TooltipIconButton>
</>
)}
{reasoningStyle === "reasoning_effort" ? (
{showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
@ -635,26 +715,61 @@ export function SharedComposer({
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: "bg-primary/10 text-primary hover:bg-primary/20",
: effectiveReasoningVisualEnabled
? "bg-primary/10 text-primary hover:bg-primary/20"
: "text-muted-foreground hover:bg-muted-foreground/15",
)}
aria-label={`Reasoning effort: ${reasoningEffort}`}
>
<LightbulbIcon className="size-3.5" />
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{reasoningEffort.charAt(0).toUpperCase() +
reasoningEffort.slice(1)}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(["low", "medium", "high"] as const).map((level) => (
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => setReasoningEffort(level)}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
}}
>
{level.charAt(0).toUpperCase() + level.slice(1)}
{reasoningEffort === level ? " \u2713" : ""}
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@ -662,31 +777,47 @@ export function SharedComposer({
) : (
<button
type="button"
disabled={reasoningDisabled}
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningAlwaysOn) return;
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: (reasoningEnabled || reasoningAlwaysOn)
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
reasoningLockedOn
? "cursor-not-allowed bg-primary/10 text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
)}
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
aria-label={
reasoningLockedOn
? "Thinking is required for this model"
: effectiveReasoningEnabled
? "Disable thinking"
: "Enable thinking"
}
>
{(reasoningEnabled || reasoningAlwaysOn) && !reasoningDisabled ? (
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
)}
)
) : null}
{supportsPreserveThinking && (
<button
type="button"
@ -784,6 +915,7 @@ export function SharedComposer({
className="size-8 rounded-full"
onClick={send}
disabled={!canSend}
aria-label="Send message"
>
<ArrowUpIcon className="size-4" />
</TooltipIconButton>

View file

@ -26,13 +26,30 @@ const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
export type ReasoningStyle = "enable_thinking" | "reasoning_effort";
export type ReasoningEffort = "low" | "medium" | "high";
export type ReasoningEffort =
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "max"
| "xhigh";
function loadReasoningEffort(fallback: ReasoningEffort): ReasoningEffort {
if (!canUseStorage()) return fallback;
try {
const raw = localStorage.getItem(REASONING_EFFORT_KEY);
if (raw === "low" || raw === "medium" || raw === "high") return raw;
if (
raw === "none" ||
raw === "minimal" ||
raw === "low" ||
raw === "medium" ||
raw === "high" ||
raw === "max" ||
raw === "xhigh"
) {
return raw;
}
return fallback;
} catch {
return fallback;
@ -196,8 +213,19 @@ type ChatRuntimeStore = {
supportsReasoning: boolean;
reasoningAlwaysOn: boolean;
reasoningEnabled: boolean;
/**
* The model id the OpenRouter router actually picked for the most recent
* stream when the active checkpoint is the openrouter/free meta-model.
* Updated each time a chunk arrives carrying a non-empty `model` field
* that differs from the requested id. Cleared when a non-OpenRouter
* model is selected. Used purely for UI display appended after
* `openrouter/free:` in the active model chip.
*/
lastOpenRouterChosenModel: string | null;
reasoningStyle: ReasoningStyle;
reasoningEffort: ReasoningEffort;
supportsReasoningOff: boolean;
reasoningEffortLevels: readonly ReasoningEffort[];
supportsPreserveThinking: boolean;
preserveThinking: boolean;
supportsTools: boolean;
@ -246,6 +274,7 @@ type ChatRuntimeStore = {
setSettingsPanelOpen: (open: boolean) => void;
clearCheckpoint: () => void;
setReasoningEnabled: (enabled: boolean) => void;
setLastOpenRouterChosenModel: (chosen: string | null) => void;
setReasoningStyle: (style: ReasoningStyle) => void;
setReasoningEffort: (effort: ReasoningEffort) => void;
setPreserveThinking: (value: boolean) => void;
@ -285,6 +314,9 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
reasoningEnabled: true,
reasoningStyle: "enable_thinking",
reasoningEffort: loadReasoningEffort("medium"),
supportsReasoningOff: false,
reasoningEffortLevels: ["low", "medium", "high"],
lastOpenRouterChosenModel: null,
supportsPreserveThinking: false,
preserveThinking: loadBool(PRESERVE_THINKING_KEY, false),
supportsTools: false,
@ -394,6 +426,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
reasoningAlwaysOn: false,
reasoningEnabled: true,
reasoningStyle: "enable_thinking",
supportsReasoningOff: false,
reasoningEffortLevels: ["low", "medium", "high"],
supportsPreserveThinking: false,
supportsTools: false,
toolsEnabled: false,
@ -410,6 +444,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
loadedChatTemplateOverride: null,
})),
setReasoningEnabled: (reasoningEnabled) => set({ reasoningEnabled }),
setLastOpenRouterChosenModel: (lastOpenRouterChosenModel) =>
set({ lastOpenRouterChosenModel }),
setReasoningStyle: (reasoningStyle) => set({ reasoningStyle }),
setReasoningEffort: (reasoningEffort) =>
set(() => {

View file

@ -0,0 +1,24 @@
// 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 {
loadExternalProviders,
saveExternalProviders,
type ExternalProviderConfig,
} from "../external-providers";
interface ExternalProvidersState {
providers: ExternalProviderConfig[];
setProviders: (providers: ExternalProviderConfig[]) => void;
}
export const useExternalProvidersStore = create<ExternalProvidersState>(
(set) => ({
providers: loadExternalProviders(),
setProviders: (providers) => {
set({ providers });
saveExternalProviders(providers);
},
}),
);

View file

@ -174,27 +174,43 @@ export interface AudioGenerationResponse {
}>;
}
export type OpenAIMessageContent =
| string
| Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
>;
export interface OpenAIChatMessage {
role: "system" | "user" | "assistant";
content: string;
content: OpenAIMessageContent;
}
export interface OpenAIChatCompletionsRequest {
model: string;
messages: OpenAIChatMessage[];
stream: boolean;
temperature: number;
top_p: number;
/** Reasoning-class OpenAI models reject these — caller may omit. */
temperature?: number;
top_p?: number;
max_tokens: number;
top_k: number;
min_p: number;
repetition_penalty: number;
presence_penalty: number;
top_k?: number;
min_p?: number;
repetition_penalty?: number;
presence_penalty?: number;
image_base64?: string;
audio_base64?: string;
use_adapter?: boolean | string | null;
enable_thinking?: boolean | null;
reasoning_effort?: "low" | "medium" | "high" | null;
reasoning_effort?:
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "max"
| "xhigh"
| null;
preserve_thinking?: boolean | null;
enable_tools?: boolean | null;
enabled_tools?: string[];
@ -203,6 +219,11 @@ export interface OpenAIChatCompletionsRequest {
tool_call_timeout?: number;
session_id?: string;
cancel_id?: string;
provider_id?: string;
provider_type?: string;
external_model?: string;
encrypted_api_key?: string;
provider_base_url?: string | null;
}
export interface OpenAIChatDelta {

View file

@ -8,16 +8,30 @@ type ContentPart = NonNullable<ChatModelRunResult["content"]>[number];
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
// ContentPart from @assistant-ui/react has readonly fields, so we cannot
// do `last.text += text` to coalesce adjacent same-type parts — tsc fails
// with TS2540 "Cannot assign to 'text' because it is a read-only property".
// Instead, replace the last element with a fresh merged object: same
// allocation cost as the mutation path but type-safe.
function appendTextPart(parts: ContentPart[], text: string): void {
if (text) {
parts.push({ type: "text", text });
if (!text) return;
const last = parts.at(-1);
if (last?.type === "text") {
parts[parts.length - 1] = { type: "text", text: last.text + text };
return;
}
parts.push({ type: "text", text });
}
function appendReasoningPart(parts: ContentPart[], text: string): void {
if (text) {
parts.push({ type: "reasoning", text });
if (!text) return;
const last = parts.at(-1);
if (last?.type === "reasoning") {
parts[parts.length - 1] = { type: "reasoning", text: last.text + text };
return;
}
parts.push({ type: "reasoning", text });
}
export function parseAssistantContent(

View file

@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@ -14,11 +14,42 @@ const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
const STUDIO_LOCAL_PULL_CMD = "git pull --ff-only";
const STUDIO_LOCAL_UPDATE_CMD = "unsloth studio update --local";
const STUDIO_LOCAL_FALLBACK_UNIX_CMD = "./install.sh --local";
const STUDIO_LOCAL_FALLBACK_WINDOWS_CMD = ".\\install.ps1 --local";
export type UpdateShell = "windows" | "unix";
export type UpdateInstallSource =
| "pypi"
| "editable"
| "local_path"
| "vcs"
| "local_repo"
| "unknown";
type UpdateInstallSourceState = UpdateInstallSource | "loading";
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
return shell === "windows"
? "Open PowerShell and run:"
: "Open Terminal and run:";
}
function isLocalInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
return Boolean(
installSource &&
installSource !== "pypi" &&
installSource !== "unknown" &&
installSource !== "loading",
);
}
function isUnknownInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
return installSource === "unknown";
}
function CopyableCommand({
@ -54,7 +85,7 @@ function CopyableCommand({
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
readOnly={true}
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
@ -68,7 +99,10 @@ function CopyableCommand({
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
<HugeiconsIcon
icon={Tick02Icon}
className="size-4 text-emerald-600"
/>
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
@ -77,24 +111,38 @@ function CopyableCommand({
);
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
export function UpdateStudioInstructions({
className,
defaultShell,
installSource,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
installSource?: UpdateInstallSourceState | null;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(defaultShell);
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
const localInstallSource = isLocalInstallSource(installSource);
const checkoutInstallSource =
installSource === "editable" || installSource === "local_repo";
const packagedSourceInstall =
installSource === "vcs" || installSource === "local_path";
const loadingInstallSource = installSource === "loading";
const unknownInstallSource = isUnknownInstallSource(installSource);
const fadeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
const fadeInitial = prefersReducedMotion
? { opacity: 1 }
: { opacity: 0, y: 2 };
const fadeAnimate = { opacity: 1, y: 0 };
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
const fadeExit = prefersReducedMotion
? { opacity: 1 }
: { opacity: 0, y: -2 };
useEffect(() => {
setShell(defaultShell);
@ -133,9 +181,9 @@ export function UpdateStudioInstructions({
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
windows
? "text-muted-foreground hover:text-emerald-600"
: "text-foreground",
)}
aria-pressed={!windows}
>
@ -143,43 +191,157 @@ export function UpdateStudioInstructions({
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
{loadingInstallSource ? (
<p className="text-xs text-muted-foreground leading-relaxed">
Checking how Studio was installed
</p>
) : localInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Source or local install detected. To avoid replacing it with PyPI,
update from the checkout or source you originally installed from.
</p>
{checkoutInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Pull latest changes from your Unsloth repo checkout, then update
Studio locally:
</p>
<CopyableCommand
command={STUDIO_LOCAL_PULL_CMD}
copyLabel="git pull command"
/>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel="local update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
If the Studio update command is unavailable, run the local
installer from that checkout:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`local-fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
}
copyLabel="local installer command"
/>
</motion.div>
</AnimatePresence>
</>
) : null}
{packagedSourceInstall ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
This looks like a source or VCS package install. Reinstall from
the original local path or Git URL you used.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
If you still have the Unsloth repo checkout, run the local
installer from that checkout:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`source-fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
}
copyLabel="local installer command"
/>
</motion.div>
</AnimatePresence>
</>
) : null}
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
) : unknownInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Studio could not detect how it was installed. Check how you
installed Studio first, then choose the matching update path.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
For curl or PyPI installs, run:
</p>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
command={STUDIO_UPDATE_CMD}
copyLabel="update command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
For local checkout installs, update from that checkout instead and
use the local update command:
</p>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel="local update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
) : (
<>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand
command={STUDIO_UPDATE_CMD}
copyLabel="update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
)}
</div>
);
}

View file

@ -10,6 +10,7 @@ import {
import { cn } from "@/lib/utils";
import {
Cancel01Icon,
CloudIcon,
Globe02Icon,
HelpCircleIcon,
Message01Icon,
@ -20,11 +21,15 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { useSettingsDialogStore, type SettingsTab } from "./stores/settings-dialog-store";
import {
useSettingsDialogStore,
type SettingsTab,
} from "./stores/settings-dialog-store";
import { AboutTab } from "./tabs/about-tab";
import { ApiKeysTab } from "./tabs/api-keys-tab";
import { AppearanceTab } from "./tabs/appearance-tab";
import { ChatTab } from "./tabs/chat-tab";
import { ConnectionsTab } from "./tabs/connections-tab";
import { GeneralTab } from "./tabs/general-tab";
import { ProfileTab } from "./tabs/profile-tab";
@ -40,6 +45,7 @@ const TABS: TabDef[] = [
{ id: "profile", label: "Profile", icon: UserIcon },
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
{ id: "chat", label: "Chat", icon: Message01Icon },
{ id: "connections", label: "Cloud", icon: CloudIcon, badge: "New" },
{ id: "api-keys", label: "API", icon: Globe02Icon, badge: "New" },
{ id: "about", label: "Help", icon: HelpCircleIcon },
];
@ -54,6 +60,8 @@ function renderTab(tab: SettingsTab) {
return <AppearanceTab />;
case "chat":
return <ChatTab />;
case "connections":
return <ConnectionsTab />;
case "api-keys":
return <ApiKeysTab />;
case "about":
@ -72,6 +80,7 @@ export function SettingsDialog() {
profile: null,
appearance: null,
chat: null,
connections: null,
"api-keys": null,
about: null,
});
@ -100,9 +109,9 @@ export function SettingsDialog() {
<DialogDescription className="sr-only">
Manage your Unsloth Studio preferences.
</DialogDescription>
<div className="flex h-full min-h-0">
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
<nav className="flex flex-col gap-0.5">
<div className="flex h-full min-h-0 max-sm:flex-col">
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 max-sm:w-full max-sm:border-r-0 max-sm:border-b">
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
@ -115,6 +124,7 @@ export function SettingsDialog() {
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[32px] items-center gap-2.5 rounded-[8px] px-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
"max-sm:shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
@ -142,7 +152,9 @@ export function SettingsDialog() {
strokeWidth={1.75}
className="relative z-10 size-icon"
/>
<span className="relative z-10 min-w-0 truncate">{tab.label}</span>
<span className="relative z-10 min-w-0 truncate">
{tab.label}
</span>
{tab.badge ? (
<span className="relative z-10 ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{tab.badge}
@ -154,7 +166,7 @@ export function SettingsDialog() {
</nav>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col">
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}
@ -163,7 +175,7 @@ export function SettingsDialog() {
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
{renderTab(activeTab)}
</div>
</main>

View file

@ -8,6 +8,7 @@ export type SettingsTab =
| "profile"
| "appearance"
| "chat"
| "connections"
| "api-keys"
| "about";
@ -29,8 +30,18 @@ function loadInitialTab(): SettingsTab {
} catch {
return "general";
}
const valid: SettingsTab[] = ["general", "profile", "appearance", "chat", "api-keys", "about"];
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
const valid: SettingsTab[] = [
"general",
"profile",
"appearance",
"chat",
"connections",
"api-keys",
"about",
];
return valid.includes(stored as SettingsTab)
? (stored as SettingsTab)
: "general";
}
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({

View file

@ -1,12 +1,12 @@
// 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 { Button } from "@/components/ui/button";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
import { Button } from "@/components/ui/button";
import { usePlatformStore } from "@/config/env";
import { apiUrl } from "@/lib/api-base";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import { getAuthToken } from "@/features/auth";
import { removeTrainingUnloadGuard } from "@/features/training";
import { apiUrl, isTauri } from "@/lib/api-base";
import {
ArrowUpRight01Icon,
Book03Icon,
@ -18,28 +18,108 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import {
type UpdateInstallSource,
UpdateStudioInstructions,
} from "../components/update-studio-instructions";
type ApiObject = Record<string, unknown>;
const INSTALL_SOURCE_KEY = "install_source";
const UPDATE_INSTALL_SOURCES = new Set<UpdateInstallSource>([
"pypi",
"editable",
"local_path",
"vcs",
"local_repo",
"unknown",
]);
function isUpdateInstallSource(value: unknown): value is UpdateInstallSource {
return (
typeof value === "string" &&
UPDATE_INSTALL_SOURCES.has(value as UpdateInstallSource)
);
}
async function fetchStudioVersions(): Promise<{
packageVersion: string | null;
studioVersion: string | null;
}> {
try {
const res = await fetch(apiUrl("/api/health"));
if (!res.ok) {
return { packageVersion: null, studioVersion: null };
}
const data = (await res.json()) as ApiObject;
const packageVersion = data.version;
const studioVersion = data.studio_version;
return {
packageVersion:
typeof packageVersion === "string" ? packageVersion : null,
studioVersion: typeof studioVersion === "string" ? studioVersion : null,
};
} catch {
return { packageVersion: null, studioVersion: null };
}
}
async function fetchInstallSource(): Promise<UpdateInstallSource> {
if (isTauri) {
return "unknown";
}
const token = getAuthToken();
if (!token) {
return "unknown";
}
try {
const headers = new Headers();
headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/studio/install-source"), { headers });
if (!res.ok) {
return "unknown";
}
const data = (await res.json()) as ApiObject;
const installSource = data[INSTALL_SOURCE_KEY];
return isUpdateInstallSource(installSource) ? installSource : "unknown";
} catch {
return "unknown";
}
}
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
const [version, setVersion] = useState("dev");
const [packageVersion, setPackageVersion] = useState("dev");
const [studioVersion, setStudioVersion] = useState("dev");
const [installSource, setInstallSource] = useState<
UpdateInstallSource | "loading"
>("loading");
useEffect(() => {
let canceled = false;
(async () => {
try {
const res = await fetch(apiUrl("/api/health"));
if (!res.ok) return;
const data = (await res.json()) as { version?: string };
if (!canceled && data.version) {
setVersion(data.version);
}
} catch {
// fall back to dev label
fetchStudioVersions().then((nextVersions) => {
if (canceled) {
return;
}
})();
if (nextVersions.packageVersion) {
setPackageVersion(nextVersions.packageVersion);
}
if (nextVersions.studioVersion) {
setStudioVersion(nextVersions.studioVersion);
}
});
fetchInstallSource().then((nextInstallSource) => {
if (!canceled) {
setInstallSource(nextInstallSource);
}
});
return () => {
canceled = true;
@ -56,14 +136,25 @@ export function AboutTab() {
</header>
<SettingsSection title="Studio">
<SettingsRow label="Version">
<code className="font-mono text-xs text-muted-foreground">{version}</code>
<SettingsRow label="Studio Version">
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
</code>
</SettingsRow>
<SettingsRow label="Package Version">
<code className="font-mono text-xs text-muted-foreground">
{packageVersion}
</code>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Updates">
<div className="py-2">
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
<UpdateStudioInstructions
defaultShell={defaultShell}
installSource={isTauri ? null : installSource}
showTitle={false}
/>
</div>
</SettingsSection>
@ -99,7 +190,10 @@ export function AboutTab() {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
<HugeiconsIcon
icon={MessageNotification01Icon}
className="size-3.5"
/>
Report an issue
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
@ -108,7 +202,7 @@ export function AboutTab() {
<SettingsSection title="Danger zone">
<SettingsRow
destructive
destructive={true}
label="Shut down Unsloth Studio"
description="Stops the Studio server process and ends your session."
>

View file

@ -0,0 +1,17 @@
// 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 { ChatProvidersSettings } from "@/features/chat/chat-providers-dialog";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
export function ConnectionsTab() {
const providers = useExternalProvidersStore((s) => s.providers);
const setProviders = useExternalProvidersStore((s) => s.setProviders);
return (
<ChatProvidersSettings
providers={providers}
onProvidersChange={setProviders}
/>
);
}

View file

@ -83,6 +83,7 @@ export function buildTrainingStartPayload(
save_steps: config.saveSteps,
eval_steps: config.evalSteps,
weight_decay: config.weightDecay,
max_grad_norm: 0.0,
random_seed: config.randomSeed,
packing: isEmbedding ? false : config.packing,
optim: config.optimizerType,

View file

@ -17,10 +17,41 @@ function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
type FastApiValidationError = {
loc?: unknown[];
msg?: string;
};
function formatDetail(detail: unknown): string | null {
if (typeof detail === "string" && detail) return detail;
if (!Array.isArray(detail)) return null;
const parts = detail
.map((entry) => {
if (!entry || typeof entry !== "object") return "";
const { loc, msg } = entry as FastApiValidationError;
const path = Array.isArray(loc)
? loc.filter((segment) => segment !== "body").join(".")
: "";
const message = typeof msg === "string" ? msg : "";
if (path && message) return `${path}: ${message}`;
return path || message;
})
.filter(Boolean);
return parts.length > 0 ? parts.join("; ") : null;
}
async function readError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as { detail?: string; message?: string };
return payload.detail || payload.message || `Request failed (${response.status})`;
const payload = (await response.json()) as {
detail?: unknown;
message?: string;
};
const formattedDetail = formatDetail(payload.detail);
if (formattedDetail) return formattedDetail;
if (typeof payload.message === "string" && payload.message) {
return payload.message;
}
return `Request failed (${response.status})`;
} catch {
return `Request failed (${response.status})`;
}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
import { useTrainingRuntimeStore } from "@/features/training";
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
@ -13,14 +13,18 @@ let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
export function useTrainingUnloadGuard() {
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
if (!useTrainingRuntimeStore.getState().isTrainingRunning) {
return;
}
e.preventDefault();
e.returnValue = "";
};
currentHandler = handler;
window.addEventListener("beforeunload", handler);
return () => {
if (currentHandler === handler) currentHandler = null;
if (currentHandler === handler) {
currentHandler = null;
}
window.removeEventListener("beforeunload", handler);
};
}, []);

View file

@ -16,7 +16,11 @@ export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-st
export { uploadTrainingDataset } from "./api/datasets-api";
export { listLocalModels } from "./api/models-api";
export type { LocalModelInfo } from "./api/models-api";
export type { TrainingPhase, TrainingViewData, TrainingSeriesPoint } from "./types/runtime";
export type {
TrainingPhase,
TrainingViewData,
TrainingSeriesPoint,
} from "./types/runtime";
export type {
TrainingRunSummary,
TrainingRunListResponse,

View file

@ -31,6 +31,7 @@ export interface TrainingStartRequest {
save_steps: number;
eval_steps: number;
weight_decay: number;
max_grad_norm: number;
random_seed: number;
packing: boolean;
optim: string;

View file

@ -29,7 +29,10 @@ type DesktopPreflightDisposition =
| "not_installed"
| "managed_ready"
| "managed_stale"
| "attached_ready";
| "owned_ready"
| "owned_stale"
| "attached_ready"
| "external_conflict";
interface DesktopPreflightResult {
disposition: DesktopPreflightDisposition;
@ -39,28 +42,45 @@ interface DesktopPreflightResult {
managed_bin: string | null;
}
const MANAGED_STARTUP_TIMEOUT_MS = 5 * 60_000;
const MANAGED_STARTUP_POLL_MS = 500;
type TauriInvoke = typeof import("@tauri-apps/api/core").invoke;
type ManagedStartupResult =
| { status: "ready"; port: number }
| { status: "aborted" }
| { status: "missing-port" }
| { status: "unhealthy" };
| { status: "aborted" };
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function externalConflictMessage(preflight: DesktopPreflightResult) {
if (preflight.reason === "desktop_owned_backend_active") {
return preflight.port
? `A desktop-owned Studio server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
: "A desktop-owned Studio server for this install is already running. Quit the other desktop app instance, then try again.";
}
if (preflight.reason === "desktop_owned_backend_starting") {
return "The desktop-owned Studio backend is still starting. Wait a moment, then try again.";
}
if (preflight.reason?.startsWith("desktop_owned_backend_unmanageable:")) {
return preflight.port
? `A desktop-owned Studio backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.`
: "A desktop-owned Studio backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.";
}
return preflight.port
? `A Studio server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
: "A Studio server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
}
async function waitForManagedServerReady(
invoke: TauriInvoke,
getPort: () => number | null,
shouldContinue: () => boolean,
): Promise<ManagedStartupResult> {
const deadline = Date.now() + MANAGED_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
while (true) {
if (!shouldContinue()) {
return { status: "aborted" };
}
@ -81,10 +101,6 @@ async function waitForManagedServerReady(
await wait(MANAGED_STARTUP_POLL_MS);
}
return getPort() === null
? { status: "missing-port" }
: { status: "unhealthy" };
}
export function useTauriBackend() {
@ -109,6 +125,7 @@ export function useTauriBackend() {
const externalPollAbortedRef = useRef(false);
const authFailureRef = useRef<string | null>(getTauriAuthFailure());
const elevationResumeRef = useRef<"install" | "repair" | null>(null);
const [tauriEventsReady, setTauriEventsReady] = useState(!isTauri);
function setBackendStatus(nextStatus: BackendStatus) {
if (authFailureRef.current) return;
@ -205,12 +222,24 @@ export function useTauriBackend() {
startExternalServerPoll(preflight.port);
return;
}
case "owned_ready":
if (!preflight.port) {
setBackendError("Desktop preflight found an owned backend without a port.");
return;
}
setApiBase(preflight.port);
portRef.current = preflight.port;
setIsExternalServer(false);
stopExternalServerPoll();
setRunningStatus();
return;
case "managed_ready":
setIsExternalServer(false);
stopExternalServerPoll();
setBackendStatus("starting");
await startManagedServer();
return;
case "owned_stale":
case "managed_stale":
setIsExternalServer(false);
stopExternalServerPoll();
@ -218,10 +247,17 @@ export function useTauriBackend() {
await startRepair();
} else {
setBackendError(
"Managed Studio install is too old. Run `unsloth studio update`.",
preflight.disposition === "owned_stale"
? "Desktop-owned Studio backend is too old for this desktop app. Run `unsloth studio update`, then restart Studio."
: "Managed Studio install is too old. Run `unsloth studio update`.",
);
}
return;
case "external_conflict":
setIsExternalServer(false);
stopExternalServerPoll();
setBackendError(externalConflictMessage(preflight));
return;
case "not_installed":
setBackendStatus("not-installed");
return;
@ -264,11 +300,6 @@ export function useTauriBackend() {
return;
}
const message =
startupResult.status === "missing-port"
? "Managed server started without reporting a port. Check the logs for details."
: "Server started but is not responding. Check the logs for details.";
setBackendError(message);
} catch (e) {
const msg = String(e);
if (msg.includes("already running")) {
@ -441,9 +472,9 @@ export function useTauriBackend() {
});
}, [currentStepIndex, elevationPackages, error, logs, progressDetail]);
// Initial check on mount (guarded against Strict Mode double-mount)
// Initial check on mount after Tauri event listeners are registered.
useEffect(() => {
if (mountedRef.current) return;
if (!tauriEventsReady || mountedRef.current) return;
mountedRef.current = true;
if (!isTauri) {
@ -451,7 +482,7 @@ export function useTauriBackend() {
return;
}
checkInstallAndStart();
}, []);
}, [tauriEventsReady]);
// Listen for Tauri events
useEffect(() => {
@ -460,17 +491,20 @@ export function useTauriBackend() {
let disposed = false;
import("@tauri-apps/api/event").then(({ listen }) => {
const registrations: Promise<void>[] = [];
function register<T>(
event: string,
handler: Parameters<typeof listen<T>>[1],
) {
listen<T>(event, handler).then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup.push(unlisten);
}
});
registrations.push(
listen<T>(event, handler).then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup.push(unlisten);
}
}),
);
}
register<string>("install-progress", (e) => {
@ -549,6 +583,16 @@ export function useTauriBackend() {
retry();
}
});
Promise.all(registrations)
.then(() => {
if (!disposed) setTauriEventsReady(true);
})
.catch((error) => {
if (!disposed) setBackendError(String(error));
});
}).catch((error) => {
if (!disposed) setBackendError(String(error));
});
const onAuthFailed = (event: Event) => {

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