Merge branch 'main' into fix/rocm-strix-halo-unified-memory
|
|
@ -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.
|
||||
|
|
|
|||
1238
studio/backend/core/inference/external_provider.py
Normal file
127
studio/backend/core/inference/key_exchange.py
Normal 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")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
287
studio/backend/core/inference/providers.py
Normal 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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")',
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────
|
||||
|
||||
|
|
|
|||
128
studio/backend/models/providers.py
Normal 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)"
|
||||
)
|
||||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
338
studio/backend/routes/providers.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
153
studio/backend/storage/providers_db.py
Normal 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()
|
||||
404
studio/backend/tests/test_anthropic_thinking_translation.py
Normal 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>"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
259
studio/backend/tests/test_llama_cpp_windows_nvidia_path.py
Normal 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)
|
||||
269
studio/backend/tests/test_middleware.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
432
studio/backend/tests/test_openai_responses_translation.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
609
studio/backend/tests/test_providers_api.py
Normal 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")
|
||||
241
studio/backend/tests/test_sandbox_tools.py
Normal 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
|
||||
90
studio/backend/tests/test_studio_train_validation.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
11
studio/backend/utils/_studio_release_build.py
Normal 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
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
92
studio/backend/utils/studio_version.py
Normal 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
|
||||
374
studio/backend/utils/update_status.py
Normal 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")
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
21
studio/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
6
studio/frontend/public/provider-logos/anthropic.svg
Normal 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 |
14
studio/frontend/public/provider-logos/deepseek.svg
Normal 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 |
72
studio/frontend/public/provider-logos/gemini.svg
Normal file
|
After Width: | Height: | Size: 3 MiB |
8
studio/frontend/public/provider-logos/huggingface.svg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
studio/frontend/public/provider-logos/kimi.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
19
studio/frontend/public/provider-logos/misc/meta.svg
Normal 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 |
1
studio/frontend/public/provider-logos/misc/microsoft.svg
Normal 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 |
BIN
studio/frontend/public/provider-logos/misc/minimax.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
1
studio/frontend/public/provider-logos/misc/nvidia.svg
Normal 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 |
BIN
studio/frontend/public/provider-logos/misc/perplexity.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
1
studio/frontend/public/provider-logos/misc/xai.svg
Normal 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 |
215
studio/frontend/public/provider-logos/misc/z-ai.svg
Normal 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 |
19
studio/frontend/public/provider-logos/mistral.svg
Normal 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 |
5
studio/frontend/public/provider-logos/openai.svg
Normal 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 |
1
studio/frontend/public/provider-logos/openrouter.svg
Normal 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 |
BIN
studio/frontend/public/provider-logos/qwen.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
136
studio/frontend/src/components/web/update-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
studio/frontend/src/features/chat/api-provider-logo.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
230
studio/frontend/src/features/chat/api/providers-api.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
1361
studio/frontend/src/features/chat/chat-providers-dialog.tsx
Normal 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
|
||||
|
|
|
|||
230
studio/frontend/src/features/chat/external-providers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
448
studio/frontend/src/features/chat/provider-capabilities.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||