Merge branch 'main' into studio-readme-server-flags

This commit is contained in:
Daniel Han 2026-06-18 02:38:23 -07:00 committed by GitHub
commit 541e3c110e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
316 changed files with 21929 additions and 7326 deletions

View file

@ -0,0 +1,295 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Small in-memory monitor for OpenAI-compatible API traffic."""
from __future__ import annotations
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass
from typing import Any, Optional
_MAX_ENTRIES = 50
_MAX_PROMPT_CHARS = 12000
_MAX_REPLY_CHARS = 12000
_PREVIEW_CHARS = 360
def _trim(text: Optional[str], limit: int) -> str:
if not text:
return ""
if len(text) <= limit:
return text
# Guard against limit < 3 (slice would underflow).
if limit <= 3:
return "..."[:limit]
return text[: limit - 3] + "..."
@dataclass
class ApiMonitorEntry:
id: str
endpoint: str
method: str
model: str
prompt: str
status: str
started_at: float
updated_at: float
subject: Optional[str] = None
# Monotonic anchors so duration math survives wall-clock steps (NTP).
started_monotonic: float = 0.0
finished_monotonic: Optional[float] = None
reply: str = ""
finished_at: Optional[float] = None
context_length: Optional[int] = None
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
total_tokens: Optional[int] = None
total_tokens_authoritative: bool = False
error: Optional[str] = None
def snapshot(self, *, include_details: bool = True) -> dict[str, Any]:
duration_ms = None
if self.finished_monotonic is not None:
duration_ms = max(
0,
int((self.finished_monotonic - self.started_monotonic) * 1000),
)
elif self.finished_at is not None:
duration_ms = max(0, int((self.finished_at - self.started_at) * 1000))
context_usage = None
if self.total_tokens is not None and self.context_length:
context_usage = min(1.0, max(0.0, self.total_tokens / self.context_length))
payload = {
"id": self.id,
"endpoint": self.endpoint,
"method": self.method,
"model": self.model,
"prompt_preview": _trim(self.prompt, _PREVIEW_CHARS),
"reply_preview": _trim(self.reply, _PREVIEW_CHARS),
"prompt_truncated": len(self.prompt) > _PREVIEW_CHARS,
"reply_truncated": len(self.reply) > _PREVIEW_CHARS,
"status": self.status,
"started_at": self.started_at,
"updated_at": self.updated_at,
"finished_at": self.finished_at,
"duration_ms": duration_ms,
"context_length": self.context_length,
"context_usage": context_usage,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"total_tokens": self.total_tokens,
"error": self.error,
}
if include_details:
payload["prompt"] = self.prompt
payload["reply"] = self.reply
return payload
class ApiMonitor:
def __init__(self, max_entries: int = _MAX_ENTRIES):
self._entries: deque[ApiMonitorEntry] = deque()
self._max_entries = max(0, max_entries)
self._lock = threading.Lock()
def start(
self,
*,
endpoint: str,
method: str,
model: str,
prompt: str,
context_length: Optional[int] = None,
subject: Optional[str] = None,
) -> str:
now = time.time()
entry = ApiMonitorEntry(
id = f"apireq_{uuid.uuid4().hex[:12]}",
endpoint = endpoint,
method = method,
model = model or "default",
prompt = _trim(prompt, _MAX_PROMPT_CHARS),
status = "running",
started_at = now,
updated_at = now,
subject = subject,
started_monotonic = time.monotonic(),
context_length = context_length,
)
with self._lock:
self._entries.appendleft(entry)
self._trim_terminal_locked()
return entry.id
def append_reply(self, entry_id: Optional[str], text: str) -> None:
if not entry_id or not text:
return
with self._lock:
entry = self._find_locked(entry_id)
if entry is None:
return
# Preview is capped: once the "..." marker is present the head is
# frozen, so skip the per-chunk re-concat (avoids O(n^2) on long
# generations). A reply that landed exactly on the cap has no marker
# yet, so let one more append record the truncation before freezing.
if len(entry.reply) >= _MAX_REPLY_CHARS:
if not entry.reply.endswith("..."):
entry.reply = _trim(entry.reply + text, _MAX_REPLY_CHARS)
entry.updated_at = time.time()
return
entry.reply = _trim(entry.reply + text, _MAX_REPLY_CHARS)
entry.updated_at = time.time()
def set_reply(self, entry_id: Optional[str], text: str) -> None:
if not entry_id:
return
with self._lock:
entry = self._find_locked(entry_id)
if entry is None:
return
entry.reply = _trim(text, _MAX_REPLY_CHARS)
entry.updated_at = time.time()
def set_usage(
self,
entry_id: Optional[str],
*,
prompt_tokens: Optional[int] = None,
completion_tokens: Optional[int] = None,
total_tokens: Optional[int] = None,
context_length: Optional[int] = None,
) -> None:
if not entry_id:
return
with self._lock:
entry = self._find_locked(entry_id)
if entry is None:
return
if prompt_tokens is not None:
entry.prompt_tokens = prompt_tokens
if completion_tokens is not None:
entry.completion_tokens = completion_tokens
if total_tokens is not None:
entry.total_tokens = total_tokens
entry.total_tokens_authoritative = True
elif not entry.total_tokens_authoritative and (
prompt_tokens is not None or completion_tokens is not None
):
# Derive only when no authoritative total has been set;
# a later partial chunk must not clobber a provider total.
entry.total_tokens = (entry.prompt_tokens or 0) + (entry.completion_tokens or 0)
if context_length is not None:
entry.context_length = context_length
entry.updated_at = time.time()
def finish(
self,
entry_id: Optional[str],
status: str = "completed",
) -> None:
if not entry_id:
return
with self._lock:
entry = self._find_locked(entry_id)
if entry is None:
return
# Idempotent: second call (e.g. [DONE] after the finally block
# already ran) must not move finished_*.
if entry.finished_at is not None:
return
now = time.time()
entry.status = status
entry.updated_at = now
entry.finished_at = now
entry.finished_monotonic = time.monotonic()
self._entries.remove(entry)
self._entries.appendleft(entry)
self._trim_terminal_locked()
def fail(self, entry_id: Optional[str], error: str) -> None:
if not entry_id:
return
with self._lock:
entry = self._find_locked(entry_id)
if entry is None:
return
if entry.finished_at is not None:
# Already terminal; refresh error text only.
if error:
entry.error = _trim(error, 1000)
return
now = time.time()
entry.status = "error"
entry.error = _trim(error, 1000)
entry.updated_at = now
entry.finished_at = now
entry.finished_monotonic = time.monotonic()
self._entries.remove(entry)
self._entries.appendleft(entry)
self._trim_terminal_locked()
def snapshot(
self,
*,
include_details: bool = True,
subject: Optional[str] = None,
) -> list[dict[str, Any]]:
with self._lock:
return [
entry.snapshot(include_details = include_details)
for entry in self._entries
if subject is None or entry.subject == subject
]
def get(
self,
entry_id: str,
*,
subject: Optional[str] = None,
) -> Optional[dict[str, Any]]:
with self._lock:
entry = self._find_locked(entry_id)
if entry is None:
return None
if subject is not None and entry.subject != subject:
return None
return entry.snapshot(include_details = True)
def active_count(self, *, subject: Optional[str] = None) -> int:
with self._lock:
return sum(
1
for entry in self._entries
if entry.status == "running" and (subject is None or entry.subject == subject)
)
def clear(self) -> None:
with self._lock:
self._entries.clear()
def _find_locked(self, entry_id: str) -> Optional[ApiMonitorEntry]:
for entry in self._entries:
if entry.id == entry_id:
return entry
return None
def _trim_terminal_locked(self) -> None:
terminal_seen = 0
kept: deque[ApiMonitorEntry] = deque()
for entry in self._entries:
if entry.status == "running":
kept.append(entry)
continue
if terminal_seen < self._max_entries:
kept.append(entry)
terminal_seen += 1
self._entries = kept
api_monitor = ApiMonitor()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,56 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Shared pooled httpx.AsyncClient for NON-streaming calls to the local llama-server.
Streaming generation must NOT use this. It relies on ``Connection: close`` and
``max_keepalive_connections=0`` so a client disconnect tears down the upstream
socket and stops GPU decode (PR #5749). This pooled client is only for short
request/response proxy calls (non-streaming completions, embeddings) where
reusing a connection removes per-request setup cost. Per-request ``timeout`` is
still passed at each call site.
"""
from __future__ import annotations
import asyncio
import weakref
import httpx
_LIMITS = httpx.Limits(max_connections = 64, max_keepalive_connections = 32)
def _new_client() -> httpx.AsyncClient:
try:
return httpx.AsyncClient(limits = _LIMITS)
except Exception:
# Mirror external_provider: an unsupported env proxy scheme can raise.
return httpx.AsyncClient(limits = _LIMITS, trust_env = False)
# One client per running event loop: an httpx client binds its transport to the
# loop it first runs on, so a single global instance breaks across a lifespan
# restart or a second test loop. Weak keys let a finished loop drop its client.
_clients: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, httpx.AsyncClient]" = (
weakref.WeakKeyDictionary()
)
def nonstreaming_client() -> httpx.AsyncClient:
loop = asyncio.get_running_loop()
client = _clients.get(loop)
if client is None or client.is_closed:
client = _new_client()
_clients[loop] = client
return client
async def aclose() -> None:
clients = list(_clients.values())
_clients.clear()
for client in clients:
try:
await client.aclose()
except Exception:
pass

View file

@ -13,7 +13,8 @@ Ref: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
from __future__ import annotations
from typing import Iterable, Optional
import os
from typing import Iterable, Mapping, Optional
# Each group = every alias (short + long) of one hard-denied flag.
# Extend the matching group when llama.cpp adds a new alias.
@ -124,7 +125,9 @@ def is_managed_flag(flag: str) -> bool:
# from inherited extras so they can't last-wins-override an Apply that
# re-sets the same field.
_CONTEXT_FLAGS: frozenset[str] = frozenset({"-c", "--ctx-size"})
_CACHE_FLAGS: frozenset[str] = frozenset({"-ctk", "--cache-type-k", "-ctv", "--cache-type-v"})
_CACHE_TYPE_K_FLAGS: frozenset[str] = frozenset({"-ctk", "--cache-type-k"})
_CACHE_TYPE_V_FLAGS: frozenset[str] = frozenset({"-ctv", "--cache-type-v"})
_CACHE_FLAGS: frozenset[str] = _CACHE_TYPE_K_FLAGS | _CACHE_TYPE_V_FLAGS
_SPEC_FLAGS: frozenset[str] = frozenset(
{
"--spec-default",
@ -133,13 +136,22 @@ _SPEC_FLAGS: frozenset[str] = frozenset(
"--spec-ngram-size",
"--draft-min",
"--draft-max",
# MTP path (llama.cpp #22673). --model-draft and aliases are
# Studio-managed since the separate-drafter support (Gemma 4): an
# inherited copy must not last-wins-override the auto-detected
# drafter. Explicit extras for the current load are never stripped.
# MTP path (llama.cpp #22673). The drafter selectors (local --model-draft
# and HF --spec-draft-hf aliases) are Studio-managed since the separate-
# drafter support (Gemma 4): an inherited copy must not last-wins-override
# the auto-detected drafter. Explicit extras for the current load are never
# stripped. The per-drafter tuning knobs (--spec-draft-type-*, -ngld,
# --spec-draft-device) are deliberately NOT stripped: the VRAM budget reads
# them via the same parsers the child honors, so they stay consistent on
# inherit, and stripping them would silently move a CPU-offloaded drafter
# back onto the GPU.
"--model-draft",
"-md",
"--spec-draft-model",
"--spec-draft-hf",
"-hfd",
"-hfrd",
"--hf-repo-draft",
"--spec-draft-n-max",
"--spec-draft-n-min",
"--spec-draft-p-min",
@ -274,6 +286,20 @@ def parse_cache_override(args: Optional[Iterable[str]]) -> Optional[str]:
return _last_flag_value(args, _CACHE_FLAGS)
def parse_cache_override_per_axis(
args: Optional[Iterable[str]],
) -> tuple[Optional[str], Optional[str]]:
"""Last-wins --cache-type-k / --cache-type-v values kept apart, as (k, v).
parse_cache_override collapses both axes to one last-wins value; this keeps
them separate so an asymmetric K/V can be budgeted by its heavier axis.
"""
return (
_last_flag_value(args, _CACHE_TYPE_K_FLAGS),
_last_flag_value(args, _CACHE_TYPE_V_FLAGS),
)
def resolve_cache_type_kv(
args: Optional[Iterable[str]], fallback_cache_type_kv: Optional[str]
) -> Optional[str]:
@ -309,6 +335,60 @@ def resolve_tensor_parallel(args: Optional[Iterable[str]], fallback_tensor_paral
return override.strip().lower() == "tensor"
def _env_split_mode_is_tensor(env: Optional[Mapping[str, str]] = None) -> bool:
"""True when the inherited LLAMA_ARG_SPLIT_MODE env selects tensor. Studio
emits --split-mode only on its tensor branch, so a tensor env on the layer
path would run the child tensor-parallel unbudgeted; this flips the budget
to tensor. Only tensor is heavier, so other modes are ignored."""
raw = (os.environ if env is None else env).get("LLAMA_ARG_SPLIT_MODE")
return bool(raw) and raw.strip().lower() == "tensor"
def _effective_tensor_parallel(
extra_args: Optional[Iterable[str]],
tensor_parallel: bool,
env: Optional[Mapping[str, str]] = None,
) -> bool:
"""Tensor-parallel decision including the inherited LLAMA_ARG_SPLIT_MODE env.
resolve_tensor_parallel (extras + toggle), flipped on when extras set no split
mode but the child inherits a tensor split env. Shared by load_model (which
budgets and launches it) and the tensor-fallback wrapper (so an env-only
tensor crash still retries layer split)."""
resolved = resolve_tensor_parallel(extra_args, tensor_parallel)
if (
not resolved
and parse_split_mode_override(extra_args) is None
and _env_split_mode_is_tensor(env)
):
return True
return resolved
def _tensor_parallel_matches_loaded(
extra_args: Optional[Iterable[str]],
requested_tensor_parallel: bool,
loaded_tensor_parallel: bool,
env: Optional[Mapping[str, str]] = None,
) -> bool:
"""Whether a duplicate load request matches a loaded server's tensor state.
Env-only tensor mode is a launch hint load_model may downgrade to layer split
(capacity/buffer), scrubbing the child env. So only let an inherited tensor env
raise a match against a server that *actually* launched tensor; on a downgraded
(layer) server the env is ignored, and an identical request would downgrade the
same way -- avoiding an endless reload of a healthy server."""
requested = resolve_tensor_parallel(extra_args, requested_tensor_parallel)
if (
loaded_tensor_parallel
and not requested
and parse_split_mode_override(extra_args) is None
and _env_split_mode_is_tensor(env)
):
requested = True
return requested == loaded_tensor_parallel
_MMPROJ_DISABLE_FLAGS: frozenset[str] = frozenset({"--no-mmproj", "--no-mmproj-auto"})
_MMPROJ_ENABLE_FLAGS: frozenset[str] = frozenset({"--mmproj-auto"})

View file

@ -0,0 +1,118 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Translate llama-server's Prometheus /metrics into a periodic, vLLM-style
engine-stats log line (generation/prompt throughput, requests in flight).
llama-server already computes these (it needs `--metrics`); this lifts them
into Studio's structured log so the terminal shows serving health, not just
per-request access lines. Emitted only while there is activity.
"""
import os
import re
import threading
import time
import urllib.request
# Prometheus body lines: "llamacpp:<name>[{labels}] <value>" (skip "#" HELP/TYPE).
_METRIC_RE = re.compile(r"^llamacpp:(\w+)(?:\{[^}]*\})?\s+([0-9.eE+-]+)", re.MULTILINE)
_OFF = {"0", "false", "no", "off"}
class LlamaServerStatsLogger:
"""Daemon poller that logs vLLM-style engine stats from llama-server.
Keeps retrying through transient scrape failures; the backend stops it via
stop() on unload/reload, so a brief /metrics stall does not silence stats.
"""
def __init__(
self,
base_url,
logger,
interval_s = 10.0,
):
self._url = f"{base_url.rstrip('/')}/metrics"
self._log = logger
self._interval = max(1.0, float(interval_s))
self._stop = threading.Event()
self._thread = None
def start(self):
if self._thread is None:
self._thread = threading.Thread(target = self._run, name = "llama-stats", daemon = True)
self._thread.start()
def stop(self):
self._stop.set()
def _scrape(self):
try:
with urllib.request.urlopen(self._url, timeout = 3) as r:
if r.status != 200:
return None
body = r.read().decode("utf-8", "replace")
except Exception:
return None
out = {}
for k, v in _METRIC_RE.findall(body):
try: # a malformed value must not kill the daemon thread
out[k] = float(v)
except ValueError:
continue
return out
def _run(self):
misses = 0
prev = None # (monotonic_t, tokens_predicted_total, prompt_tokens_total)
while not self._stop.wait(self._interval):
m = self._scrape()
if not m:
misses += 1
if misses == 3: # transient stall (load/GC); keep polling.
self._log.debug("engine_stats: /metrics scrape failing, still retrying")
continue # real shutdown is driven by stop() from _kill_process
misses = 0
# Generation tokens come from tokens_predicted_total (counter) and
# predicted_tokens_seconds (gauge); n_decode_total counts
# llama_decode() calls, not tokens, so it must not feed tok/s.
now = time.monotonic()
predicted = m.get("tokens_predicted_total", 0.0)
prompt = m.get("prompt_tokens_total", 0.0)
gen_delta = prompt_delta = 0.0
if prev is not None and now > prev[0]:
dt = now - prev[0]
gen_delta = max(0.0, (predicted - prev[1]) / dt)
prompt_delta = max(0.0, (prompt - prev[2]) / dt)
prev = (now, predicted, prompt)
# Prefer llama.cpp's own throughput gauges; fall back to the counter
# delta for binaries that expose only the counters.
gen_tps = m.get("predicted_tokens_seconds") or gen_delta
prompt_tps = m.get("prompt_tokens_seconds") or prompt_delta
running, waiting = (
int(m.get("requests_processing", 0)),
int(m.get("requests_deferred", 0)),
)
# Gate on real activity this tick so a stale gauge never logs at idle.
if running or waiting or gen_delta or prompt_delta:
self._log.info(
"engine_stats",
gen_tok_s = round(float(gen_tps), 1),
prompt_tok_s = round(float(prompt_tps), 1),
running = running,
waiting = waiting,
)
def maybe_start_stats_logger(base_url, logger):
"""Start a stats logger unless UNSLOTH_STUDIO_ENGINE_STATS disables it."""
if (os.environ.get("UNSLOTH_STUDIO_ENGINE_STATS", "1") or "").strip().lower() in _OFF:
return None
try:
interval = float(os.environ.get("UNSLOTH_STUDIO_ENGINE_STATS_INTERVAL_S", "10"))
except ValueError:
interval = 10.0
sl = LlamaServerStatsLogger(base_url, logger, interval)
sl.start()
return sl

View file

@ -30,15 +30,15 @@ from pathlib import Path
from typing import Any, Generator, Optional, Tuple, Union
from utils.hardware import prepare_gpu_selection
# Re-exported from the shared helper so GGUF, training, and inference share one
# type; kept importable here for backwards compatibility.
from utils.hf_xet_fallback import DownloadStallError
logger = get_logger(__name__)
_CTX = mp.get_context("spawn")
class DownloadStallError(RuntimeError):
"""Raised when the worker reports no download progress for too long."""
# Dispatcher timeout constants (seconds)
_DISPATCH_READ_TIMEOUT = 30.0
_DISPATCH_POLL_INTERVAL = 0.5

View file

@ -13,7 +13,7 @@ import logging
from typing import Awaitable, Callable, Optional
from core.inference.llama_server_args import (
resolve_tensor_parallel,
_effective_tensor_parallel,
strip_split_mode_only,
)
@ -34,18 +34,20 @@ async def load_with_tensor_fallback(
True on success; it *raises* on a hard crash (llama-server aborts on some
archs / older builds), which is treated the same as a False return.
Tensor mode can be requested by the toggle or by a ``--split-mode tensor``
in ``extra_args`` (an allowed shadow flag), so the retry is keyed on whether
tensor mode is actually engaged, and it strips ``--split-mode`` from the
extras so the layer retry can't relaunch the same failing tensor load. A
non-tensor load keeps its original contract and propagates exceptions.
Tensor mode can be requested by the toggle, by a ``--split-mode tensor`` in
``extra_args`` (an allowed shadow flag), or by an inherited
``LLAMA_ARG_SPLIT_MODE=tensor`` env (load_model engages it the same way), so
the retry is keyed on whether tensor mode is actually engaged, and it forces
``--split-mode layer`` on the retry so neither leftover extras nor the
inherited tensor env can relaunch the same failing tensor load. A non-tensor
load keeps its original contract and propagates exceptions.
``cancelled()`` distinguishes a real tensor-start failure from a user
cancellation: ``attempt_load`` also returns False when the load was
cancelled, so without this the helper would restart a load the user just
cancelled.
"""
tensor_requested = resolve_tensor_parallel(extra_args, requested_tensor)
tensor_requested = _effective_tensor_parallel(extra_args, requested_tensor)
try:
success = await attempt_load(requested_tensor, extra_args)
except Exception as exc:
@ -67,4 +69,8 @@ async def load_with_tensor_fallback(
"(this model may not support tensor parallelism)",
label,
)
return await attempt_load(False, strip_split_mode_only(extra_args))
# Force --split-mode layer (CLI wins over env) so neither leftover extras nor
# an inherited LLAMA_ARG_SPLIT_MODE=tensor can re-engage tensor and re-crash
# the retry; load_model and the child both honor the explicit layer override.
layer_extras = strip_split_mode_only(extra_args) or []
return await attempt_load(False, [*layer_extras, "--split-mode", "layer"])

View file

@ -217,6 +217,12 @@ class TrainingBackend:
self._db_config: Optional[dict] = None
self._db_started_at: Optional[str] = None
# Xet -> HTTP model-load fallback state (config kept for the respawn).
self._last_full_config: Optional[dict] = None
self._in_model_load: bool = False
self._xet_fallback_used: bool = False
self._needs_xet_respawn: bool = False
logger.info("TrainingBackend initialized (subprocess mode)")
# ------------------------------------------------------------------
@ -311,6 +317,8 @@ class TrainingBackend:
"trust_remote_code": kwargs.get("trust_remote_code", False),
"gpu_ids": kwargs.get("gpu_ids"),
"s3_config": kwargs.get("s3_config"),
# Flipped to True only by the HTTP-fallback respawn after a stall.
"disable_xet": kwargs.get("disable_xet", False),
}
# Full finetuning always runs in 16-bit; LoRA/QLoRA/CPT keep the request.
@ -386,6 +394,11 @@ class TrainingBackend:
self._db_total_steps_set = False
self._db_config = _sanitize_db_config(config)
self._db_started_at = datetime.now(timezone.utc).isoformat()
# Start each job Xet-first; keep config so a stall can respawn over HTTP.
self._last_full_config = config
self._in_model_load = False
self._xet_fallback_used = False
self._needs_xet_respawn = False
# Assign subprocess handles after state reset.
self._event_queue = event_queue
@ -446,6 +459,97 @@ class TrainingBackend:
output_dir,
)
def _handle_stall_event(self, event: dict) -> None:
"""A worker reported a no-progress download stall.
On the first model-load, terminate the worker so the pump loop respawns it
over HTTP. A later stall (already on HTTP, or outside model-load) surfaces
as an error instead.
"""
msg = event.get("message", "Download stalled")
with self._lock:
recover = self._in_model_load and not self._xet_fallback_used
proc = self._proc
if recover:
self._xet_fallback_used = True
self._needs_xet_respawn = True
self._progress.status_message = (
"Model download stalled on Xet; retrying over HTTP..."
)
else:
self._progress.error = self._progress.error or (
"Model download stalled even over HTTP -- check your network connection"
)
if recover:
logger.warning("Training model-load stalled on Xet; respawning over HTTP: %s", msg)
else:
logger.error("Training download stalled with no further fallback: %s", msg)
# Terminate either way so the pump loop proceeds (respawn or finalize).
if proc is not None and proc.is_alive():
proc.terminate()
def _respawn_worker_disable_xet(self) -> None:
"""Respawn the worker once with HF_HUB_DISABLE_XET=1 after a model-load
stall. Runs on the exiting pump thread, reaps the terminated worker, and
starts a fresh worker + pump. DB/progress run-state is preserved so the
history row is not duplicated; the new worker re-formats and loads over HTTP.
"""
config = self._last_full_config
if config is None:
logger.error("Cannot respawn training worker: no stored config")
return
with self._lock:
old_proc = self._proc
if old_proc is not None:
old_proc.join(timeout = 5.0)
if old_proc.is_alive():
old_proc.kill()
old_proc.join(timeout = 2.0)
config = {**config, "disable_xet": True}
self._last_full_config = config
logger.warning("Respawning training worker with HF_HUB_DISABLE_XET=1 after Xet stall")
from .worker import run_training_process
try:
with native_path_secret_removed_for_child_start():
event_queue = _CTX.Queue()
stop_queue = _CTX.Queue()
new_proc = _CTX.Process(
target = run_without_native_path_secret,
args = (run_training_process,),
kwargs = {
"event_queue": event_queue,
"stop_queue": stop_queue,
"config": config,
},
daemon = True,
)
new_proc.start()
except Exception:
logger.error("Failed to respawn training subprocess", exc_info = True)
with self._lock:
self._progress.is_training = False
self._progress.error = "Failed to recover stalled model download"
self._ensure_db_run_created()
self._finalize_run_in_db(
status = "error",
error_message = "Failed to recover stalled model download",
)
return
logger.info("Training subprocess respawned with Xet disabled (pid=%s)", new_proc.pid)
new_pump = threading.Thread(target = self._pump_loop, daemon = True)
with self._lock:
self._in_model_load = False
self._event_queue = event_queue
self._stop_queue = stop_queue
self._proc = new_proc
self._pump_thread = new_pump
new_pump.start()
def is_training_active(self) -> bool:
"""Check if training is currently active."""
with self._lock:
@ -566,6 +670,14 @@ class TrainingBackend:
for e in self._drain_queue(self._event_queue):
self._handle_event(e)
# Model-load stall: respawn over HTTP instead of finalizing as failure.
# Runs on THIS exiting pump thread and starts a fresh pump (never joins
# the current thread); DB run-state is preserved.
if self._needs_xet_respawn:
self._needs_xet_respawn = False
self._respawn_worker_disable_xet()
return
# Mark done if no explicit complete/error was received.
with self._lock:
if self._progress.is_training:
@ -597,6 +709,19 @@ class TrainingBackend:
db_action: Optional[str] = None
db_action_kwargs: dict = {}
# Model-load lifecycle + stall recovery (no DB metrics); handled first.
if etype == "model_load_started":
with self._lock:
self._in_model_load = True
return
if etype == "model_load_completed":
with self._lock:
self._in_model_load = False
return
if etype == "stall":
self._handle_stall_event(event)
return
with self._lock:
if etype == "progress":
self._progress.step = event.get("step", self._progress.step)

View file

@ -1987,6 +1987,19 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["PYTHONWARNINGS"] = "ignore" # before imports
# HTTP-fallback respawn: disable Xet before any huggingface_hub import (the
# var is read at import time). Mirrors core/inference/worker.py.
from utils.hf_xet_fallback import child_should_disable_xet
if child_should_disable_xet(config):
os.environ["HF_HUB_DISABLE_XET"] = "1"
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
print(
"Xet transport disabled for this training worker (HF_HUB_DISABLE_XET=1).",
file = sys.stderr,
flush = True,
)
# Offline auto-detect: skip ~25s of HF retries per call when DNS is dead.
if "HF_HUB_OFFLINE" not in os.environ:
import socket as _socket
@ -2706,18 +2719,33 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
cpt_trains_embeddings = False
# ── 4c. Load training model (uses VRAM — dataset already formatted) ──
# Watchdog lets the parent recover a stalled Xet download via respawn.
_send_status(event_queue, "Loading model...")
success = trainer.load_model(
model_name = model_name,
max_seq_length = config["max_seq_length"],
load_in_4bit = config["load_in_4bit"],
full_finetuning = not use_lora,
hf_token = hf_token,
is_dataset_image = config.get("is_dataset_image", False),
is_dataset_audio = config.get("is_dataset_audio", False),
trust_remote_code = config.get("trust_remote_code", False),
gpu_ids = config.get("resolved_gpu_ids"),
from utils.hf_xet_fallback import start_watchdog
event_queue.put({"type": "model_load_started", "ts": time.time()})
_load_watchdog_stop = start_watchdog(
repo_ids = [model_name],
on_stall = lambda msg: event_queue.put(
{"type": "stall", "message": msg, "ts": time.time()}
),
xet_disabled = os.environ.get("HF_HUB_DISABLE_XET") == "1",
)
try:
success = trainer.load_model(
model_name = model_name,
max_seq_length = config["max_seq_length"],
load_in_4bit = config["load_in_4bit"],
full_finetuning = not use_lora,
hf_token = hf_token,
is_dataset_image = config.get("is_dataset_image", False),
is_dataset_audio = config.get("is_dataset_audio", False),
trust_remote_code = config.get("trust_remote_code", False),
gpu_ids = config.get("resolved_gpu_ids"),
)
finally:
_load_watchdog_stop.set()
event_queue.put({"type": "model_load_completed", "ts": time.time()})
if not success or trainer.should_stop:
if trainer.should_stop:
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})

View file

@ -525,33 +525,51 @@ async def get_gguf_variants_response(
return False
def _any_mmproj_cached(filenames: frozenset[str]) -> bool:
return any(
if any(
by_filename.get(name.lower()) is not None
for by_filename in cached_filenames_by_snapshot
for name in filenames
):
return True
return any(
_is_mmproj_filename(name.rsplit("/", 1)[-1])
for by_filename in cached_filenames_by_snapshot
for name in by_filename
)
def _quant_bytes_present(quant: str, size_bytes: int) -> bool:
# Small rounding tolerance for symlinks vs real sizes.
if size_bytes <= 0:
return False
return any(
by_quant.get(quant, 0) >= size_bytes * 0.99
for by_quant in cached_quant_bytes_by_snapshot
)
def _is_fully_downloaded(variant) -> bool:
requirement = requirements_by_quant.get(variant.quant.lower())
if requirement is None:
if variant.size_bytes == 0:
return False
quant = variant.quant.lower()
# Allow small rounding tolerance (symlinks vs real sizes).
return any(
by_quant.get(quant, 0) >= variant.size_bytes * 0.99
for by_quant in cached_quant_bytes_by_snapshot
quant = variant.quant.lower()
requirement = requirements_by_quant.get(quant)
# Vision repos ship an mmproj adapter; any precision on disk suffices.
if (
requirement is not None
and _filenames_cached(
requirement.main_filenames,
requirement.main_size_bytes,
)
and (
not requirement.mmproj_filenames
or _any_mmproj_cached(requirement.mmproj_filenames)
)
if not _filenames_cached(
requirement.main_filenames,
requirement.main_size_bytes,
):
return True
# Byte fallback so a present quant isn't demoted by a filename mismatch;
# vision repos still need an mmproj cached (any precision).
if not _quant_bytes_present(quant, variant.size_bytes):
return False
# Vision repos ship an mmproj adapter per variant. Any mmproj
# precision on disk suffices (the loader picks whichever is present);
# requiring the API-preferred one would falsely demote variants.
if requirement.mmproj_filenames and not _any_mmproj_cached(
requirement.mmproj_filenames,
if (
requirement is not None
and requirement.mmproj_filenames
and not _any_mmproj_cached(requirement.mmproj_filenames)
):
return False
return True

View file

@ -353,6 +353,20 @@ def list_partial_gguf_variants_from_state(
return variants, has_vision
def resolve_local_gguf_path(repo_id: str, gguf_variant: Optional[str]) -> Optional[str]:
"""Absolute path to the (shard-1) GGUF file for ``repo_id`` + ``gguf_variant``
if it is already downloaded in the HF cache, else ``None``. Read-only never
triggers a download. Lets callers read header metadata before a load."""
for snapshot in iter_hf_cache_snapshots(repo_id):
variants, _ = list_local_gguf_variants(str(snapshot))
for variant in variants:
if gguf_variant is None or variant.quant == gguf_variant:
candidate = snapshot / variant.filename
if candidate.is_file():
return str(candidate)
return None
def list_gguf_variants(
repo_id: str, hf_token: Optional[str] = None
) -> tuple[list[GgufVariantInfo], bool, Optional[list]]:

View file

@ -8,6 +8,7 @@ filter_sensitive_data (structlog processor for sanitization), and
get_logger (factory for structured loggers).
"""
import os
import re
import time
@ -17,6 +18,31 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
from utils.native_path_leases import redact_native_paths
logger = structlog.get_logger(__name__)
def _env_int(name: str, default: int) -> int:
try:
raw = (os.environ.get(name) or "").strip()
return int(raw) if raw else default
except ValueError:
return default
# Drop duplicate successful-GET access logs repeated within the window: the SPA
# fans one cache invalidation into many identical list fetches; only the first
# informs. Loading polls, mutations, and errors are unaffected. 0 = log all.
_ACCESS_LOG_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_DEDUP_MS", 300)
# Pure-liveness/UI polls whose access line carries no signal beyond "client still
# polling" (state changes are logged by their own modules). Collapsed to a longer
# heartbeat instead of one line per poll; first hit and any error still log. 0 = off.
_QUIET_POLL_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_POLL_DEDUP_MS", 10000)
_QUIET_POLL_PATHS = {
"/api/health",
"/api/auth/status",
"/api/inference/status",
"/api/inference/monitor",
}
_DEDUP_MAP_MAX = 4096
_NATIVE_PATH_LEASE_RE = re.compile(
r"(?i)(\b(?:native_path_lease|nativePathLease)[\"']?\s*[:=]\s*[\"']?)[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"
)
@ -43,6 +69,30 @@ class LoggingMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
# (method, path, query, status_code) -> monotonic ts of the last EMITTED log.
self._last_log: dict[tuple[str, str, bytes, int], float] = {}
def _is_redundant_repeat(
self, method: str, path: str, query: bytes, status_code: int, now: float
) -> bool:
"""True if an identical GET/2xx log fired < window ago. The query string
is part of the identity, so distinct query-driven GETs are not collapsed.
Mutations and non-2xx are never deduped. Quiet-poll paths use a longer
heartbeat window. Stamps only on emit, so steady polls still log."""
if method != "GET" or not (200 <= status_code < 300):
return False
window_ms = _QUIET_POLL_DEDUP_MS if path in _QUIET_POLL_PATHS else _ACCESS_LOG_DEDUP_MS
if window_ms <= 0:
return False
key = (method, path, query, status_code)
last = self._last_log.get(key)
if last is not None and (now - last) * 1000.0 < window_ms:
return True
self._last_log[key] = now
if len(self._last_log) > _DEDUP_MAP_MAX:
cutoff = now - (max(_ACCESS_LOG_DEDUP_MS, _QUIET_POLL_DEDUP_MS) / 1000.0)
self._last_log = {k: v for k, v in self._last_log.items() if v >= cutoff}
return False
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
@ -78,13 +128,16 @@ class LoggingMiddleware:
)
raise
else:
if not excluded:
end_time = time.perf_counter()
if not excluded and not self._is_redundant_repeat(
scope["method"], path, scope.get("query_string", b""), status_code, end_time
):
logger.info(
"request_completed",
method = scope["method"],
path = path,
status_code = status_code,
process_time_ms = round((time.perf_counter() - start_time) * 1000, 2),
process_time_ms = round((end_time - start_time) * 1000, 2),
)

View file

@ -15,6 +15,15 @@ from dataclasses import asdict
# Suppress C-level dependency warnings globally
os.environ["PYTHONWARNINGS"] = "ignore"
# Pin GPU index ordering to PCI bus id before any torch import creates a CUDA
# context. Without this, torch/CUDA default to FASTEST_FIRST while nvidia-smi
# (and Studio's VRAM probes) use PCI-bus order, so a GPU index chosen from
# nvidia-smi data can resolve to a different physical card via
# CUDA_VISIBLE_DEVICES. setdefault so an explicit user override wins. See
# utils/hardware/hardware.py for the full rationale; set here too so the entry
# process is covered before its heavy ML imports.
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
# ── Windows AMD ROCm DLL injection ──────────────────────────────────────────
# Python 3.8+ ignores PATH for extension modules; register ROCm bin dirs with
# os.add_dll_directory() so amdhip64.dll etc. are found before any torch import.
@ -465,6 +474,10 @@ async def lifespan(app: FastAPI):
app.state.bootstrap_password = storage.get_bootstrap_password()
yield
from core.inference.llama_http import aclose as _close_llama_http
await _close_llama_http()
await run_lifespan_shutdown(
terminate_hub_downloads,
clear_unsloth_compiled_cache,
@ -492,8 +505,7 @@ app.add_middleware(LoggingMiddleware)
# img/media-src allow any https origin so HF model-card assets render (mirrors
# tauri.conf.json); scripts/frames/connect-src stay same-origin + HF.
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.requests import Request as _StarletteRequest # noqa: E402
from starlette.datastructures import MutableHeaders # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
@ -549,28 +561,51 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Set baseline security headers; splice per-response inline-script nonces into CSP."""
class SecurityHeadersMiddleware:
"""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))
# Omit X-Frame-Options in Colab — CSP frame-ancestors handles it, and
# DENY would block serve_kernel_port_as_iframe regardless of CSP.
if not _IS_COLAB and request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH:
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(self), geolocation=()",
)
response.headers["server"] = "unsloth-studio"
return response
Pure ASGI (not BaseHTTPMiddleware) so streaming responses are not wrapped in
an anyio stream. Header logic mirrors the prior version exactly via
MutableHeaders on the response-start message.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
async def send_wrapper(message):
if message["type"] == "http.response.start":
# ASGI headers are an iterable; coerce to a list so MutableHeaders
# can mutate in place even if a server sends a tuple or omits it.
raw = message.setdefault("headers", [])
if not isinstance(raw, list):
raw = list(raw)
message["headers"] = raw
headers = MutableHeaders(raw = raw)
# Strip the internal nonce hand-off header so it never reaches the client
nonce = headers.get(_CSP_SCRIPT_NONCE_HEADER)
if nonce is not None:
del headers[_CSP_SCRIPT_NONCE_HEADER]
headers.setdefault("Content-Security-Policy", _build_csp(nonce))
# Omit X-Frame-Options in Colab: CSP frame-ancestors handles it, and
# DENY would block serve_kernel_port_as_iframe regardless of CSP.
if not _IS_COLAB and path != _ARTIFACT_PREVIEW_FRAME_PATH:
headers.setdefault("X-Frame-Options", "DENY")
headers.setdefault("X-Content-Type-Options", "nosniff")
headers.setdefault("Referrer-Policy", "no-referrer")
headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(self), geolocation=()",
)
headers["server"] = "unsloth-studio"
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(SecurityHeadersMiddleware)

View file

@ -125,6 +125,11 @@ class ValidateModelRequest(BaseModel):
gguf_variant: Optional[str] = Field(
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
)
include_context_length: bool = Field(
False,
description = "Also read the native context length from the local GGUF header. "
"Opt-in so the normal load preflight doesn't pay for a cache scan it doesn't need.",
)
class ValidateModelResponse(BaseModel):
@ -144,6 +149,11 @@ class ValidateModelResponse(BaseModel):
False,
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
)
context_length: Optional[int] = Field(
None,
description = "Native training context length, read from the GGUF header when the file "
"is already downloaded locally; None for non-GGUF, gated, or not-yet-downloaded models.",
)
class GenerateRequest(BaseModel):

View file

@ -1,2 +1,5 @@
# Torch AO overrides (installed with --force-reinstall --no-cache-dir)
torchao==0.14.0
# torchao is installed by studio/install_python_stack.py, which selects the
# version matching the torch release actually installed in the venv (torchao's
# C++ extensions are built against one exact torch version, so a fixed pin here
# would skip them on a newer torch). See _select_torchao_spec /
# _probe_installed_torch_version in that file.

File diff suppressed because it is too large Load diff

View file

@ -55,6 +55,9 @@ class LlamaUpdateStatusResponse(BaseModel):
source_build: bool = Field(
False, description = "True when there is no marker (source build) but a prebuilt is offered."
)
update_size_bytes: Optional[int] = Field(
None, description = "Download size of the prebuilt Update would fetch, in bytes."
)
job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob)

View file

@ -33,6 +33,7 @@ from core.inference.anthropic_compat import (
AnthropicStreamEmitter,
AnthropicPassthroughEmitter,
)
from core.inference.api_monitor import ApiMonitor
from routes.inference import (
_build_tool_action_nudge,
_normalize_anthropic_openai_images,
@ -40,6 +41,7 @@ from routes.inference import (
_anthropic_requested_studio_tools,
_anthropic_passthrough_stream,
_anthropic_tool_non_streaming,
_monitor_anthropic_sse_line,
anthropic_messages,
)
from state.tool_policy import reset_tool_policy, set_tool_policy
@ -50,6 +52,46 @@ from io import BytesIO as _BytesIO
from types import SimpleNamespace
def test_streamed_anthropic_tool_use_records_api_monitor_reply(monkeypatch):
import routes.inference as inf_mod
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monitor_id = monitor.start(
endpoint = "/v1/messages",
method = "POST",
model = "m",
prompt = "hi",
)
for payload in (
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "tool_use",
"id": "toolu_1",
"name": "lookup",
"input": {},
},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "input_json_delta",
"partial_json": '{"query":"weather"}',
},
},
{"type": "content_block_stop", "index": 0},
):
_monitor_anthropic_sse_line(monitor_id, f"data: {json.dumps(payload)}")
entry = monitor.get(monitor_id)
assert entry is not None
assert entry["reply"] == 'Tool call: lookup\nInput: {"query":"weather"}'
# =====================================================================
# Tool nudge tests
# =====================================================================
@ -1385,7 +1427,7 @@ def _mock_backend(monkeypatch, **overrides):
def _gen_plain(**kwargs):
calls.append(("plain", kwargs))
yield {"type": "content", "text": "ok"}
yield "ok"
def _gen_tools(**kwargs):
calls.append(("tools", kwargs))
@ -1396,6 +1438,8 @@ def _mock_backend(monkeypatch, **overrides):
is_vision = False,
supports_tools = True,
model_identifier = "test-model",
context_length = 4096,
count_chat_tokens = lambda *args, **kwargs: 2,
generate_chat_completion = _gen_plain,
generate_chat_completion_with_tools = _gen_tools,
calls = calls,
@ -1426,6 +1470,112 @@ def _reset_policy():
class TestAnthropicMessagesToolRouting:
class _Request:
state = SimpleNamespace()
url = SimpleNamespace(path = "/v1/messages")
method = "POST"
async def is_disconnected(self):
return False
@staticmethod
def _consume_response(response):
async def _consume():
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
return chunks
return _drive(_consume())
def test_plain_non_streaming_records_api_monitor_entry(self, monkeypatch):
import routes.inference as inf_mod
_mock_backend(monkeypatch, context_length = 2048)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
payload = _basic_payload()
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
assert response.status_code == 200
[entry] = monitor.snapshot()
assert entry["endpoint"] == "/v1/messages"
assert entry["status"] == "completed"
assert entry["model"] == "test-model"
assert entry["prompt_preview"] == "user: hi"
assert entry["reply_preview"] == "ok"
assert entry["context_length"] == 2048
assert monitor.active_count() == 0
def test_tool_use_non_streaming_records_api_monitor_reply(self, monkeypatch):
import routes.inference as inf_mod
def _gen_tools(**_kwargs):
yield {
"type": "tool_start",
"tool_call_id": "call_1",
"tool_name": "lookup",
"arguments": {"query": "weather"},
}
_mock_backend(
monkeypatch,
context_length = 2048,
generate_chat_completion_with_tools = _gen_tools,
)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
payload = _basic_payload(
enable_tools = True,
tools = [{"type": "web_search_20250305", "name": "web_search"}],
)
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
assert response.status_code == 200
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply_preview"] == 'Tool call: lookup({"query": "weather"})'
def test_plain_streaming_records_active_and_completed_monitor_entry(self, monkeypatch):
import routes.inference as inf_mod
_mock_backend(monkeypatch, context_length = 2048)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
payload = _basic_payload(stream = True)
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
assert monitor.active_count() == 1
self._consume_response(response)
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply_preview"] == "ok"
assert entry["prompt_tokens"] == 2
assert entry["context_length"] == 2048
assert monitor.active_count() == 0
def test_plain_streaming_pre_response_cancel_finalizes_monitor(self, monkeypatch):
import routes.inference as inf_mod
async def _cancelled_before_response(*_args, **_kwargs):
raise asyncio.CancelledError()
_mock_backend(monkeypatch, context_length = 2048)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "_anthropic_plain_stream", _cancelled_before_response)
payload = _basic_payload(stream = True)
with pytest.raises(asyncio.CancelledError):
_drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
[entry] = monitor.snapshot()
assert entry["status"] == "cancelled"
assert monitor.active_count() == 0
def test_mixed_server_and_client_tools_rejected_with_400(self, monkeypatch):
_mock_backend(monkeypatch)
payload = _basic_payload(

View file

@ -0,0 +1,260 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from core.inference.api_monitor import ApiMonitor, _trim
def test_api_monitor_tracks_reply_usage_and_context():
monitor = ApiMonitor(max_entries = 3)
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "local-model",
prompt = "user: hello",
context_length = 100,
)
monitor.append_reply(entry_id, "hi")
monitor.append_reply(entry_id, " there")
monitor.set_usage(
entry_id,
prompt_tokens = 4,
completion_tokens = 6,
)
monitor.finish(entry_id)
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "hi there"
assert entry["total_tokens"] == 10
assert entry["context_usage"] == 0.1
assert entry["duration_ms"] is not None
def test_api_monitor_summary_omits_full_prompt_and_reply():
monitor = ApiMonitor(max_entries = 3)
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "local-model",
prompt = "p" * 500,
)
monitor.set_reply(entry_id, "r" * 500)
[summary] = monitor.snapshot(include_details = False)
assert "prompt" not in summary
assert "reply" not in summary
assert summary["prompt_preview"].endswith("...")
assert summary["reply_preview"].endswith("...")
assert summary["prompt_truncated"] is True
assert summary["reply_truncated"] is True
detail = monitor.get(entry_id)
assert detail is not None
assert detail["prompt"] == "p" * 500
assert detail["reply"] == "r" * 500
def test_api_monitor_filters_entries_by_subject():
monitor = ApiMonitor(max_entries = 3)
alice = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "alice prompt",
subject = "alice",
)
bob = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "bob prompt",
subject = "bob",
)
monitor.finish(bob)
alice_entries = monitor.snapshot(subject = "alice")
assert [entry["id"] for entry in alice_entries] == [alice]
assert monitor.get(bob, subject = "alice") is None
assert monitor.get(bob, subject = "bob")["id"] == bob
assert monitor.active_count(subject = "alice") == 1
assert monitor.active_count(subject = "bob") == 0
def test_api_monitor_keeps_bounded_recent_history():
monitor = ApiMonitor(max_entries = 2)
first = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "first",
)
second = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "second",
)
third = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "third",
)
monitor.finish(first)
monitor.finish(second)
monitor.finish(third)
entries = monitor.snapshot()
ids = [entry["id"] for entry in entries]
assert ids[0] == third
assert [entry["prompt"] for entry in entries] == ["third", "second"]
assert first not in ids
assert monitor.active_count() == 0
def test_api_monitor_keeps_running_entries_beyond_history_limit():
monitor = ApiMonitor(max_entries = 1)
running = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "running",
)
for prompt in ("done-1", "done-2", "done-3"):
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = prompt,
)
monitor.finish(entry_id)
entries = monitor.snapshot()
ids = [entry["id"] for entry in entries]
assert running in ids
assert monitor.active_count() == 1
monitor.finish(running)
[entry] = monitor.snapshot()
assert entry["id"] == running
assert entry["status"] == "completed"
assert monitor.active_count() == 0
def test_api_monitor_finish_is_idempotent():
monitor = ApiMonitor(max_entries = 2)
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "hi",
)
monitor.finish(entry_id)
first = monitor.snapshot()[0]
monitor.finish(entry_id)
second = monitor.snapshot()[0]
assert first["finished_at"] == second["finished_at"]
assert first["duration_ms"] == second["duration_ms"]
def test_api_monitor_preserves_authoritative_total_tokens():
monitor = ApiMonitor(max_entries = 2)
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "hi",
)
monitor.set_usage(
entry_id,
prompt_tokens = 10,
completion_tokens = 20,
total_tokens = 33,
)
# A later partial chunk omitting `total_tokens` must not clobber 33.
monitor.set_usage(entry_id, prompt_tokens = 11)
assert monitor.snapshot()[0]["total_tokens"] == 33
def test_api_monitor_recomputes_derived_total_tokens():
monitor = ApiMonitor(max_entries = 2)
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "hi",
)
monitor.set_usage(entry_id, prompt_tokens = 10)
assert monitor.snapshot()[0]["total_tokens"] == 10
monitor.set_usage(entry_id, completion_tokens = 20)
entry = monitor.snapshot()[0]
assert entry["prompt_tokens"] == 10
assert entry["completion_tokens"] == 20
assert entry["total_tokens"] == 30
def test_api_monitor_duration_non_negative_under_clock_step(monkeypatch):
import core.inference.api_monitor as m
fake_now = [1000.0]
monkeypatch.setattr(m.time, "time", lambda: fake_now[0])
monitor = ApiMonitor(max_entries = 1)
entry_id = monitor.start(
endpoint = "/x",
method = "POST",
model = "m",
prompt = "hi",
)
fake_now[0] = 500.0
monitor.finish(entry_id)
assert monitor.snapshot()[0]["duration_ms"] >= 0
def test_api_monitor_trim_guards_tiny_limit():
assert _trim("abcdefgh", 2) == ".."
assert _trim("abcdefgh", 0) == ""
assert _trim("abcdefgh", 3) == "..."
assert _trim("abcdefgh", 4) == "a..."
assert _trim("abcdefgh", 100) == "abcdefgh"
def test_api_monitor_append_reply_caps_without_regrowing():
import core.inference.api_monitor as m
monitor = ApiMonitor(max_entries = 1)
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "go",
)
monitor.append_reply(entry_id, "x" * (m._MAX_REPLY_CHARS + 500))
capped = monitor.snapshot()[0]["reply"]
assert len(capped) == m._MAX_REPLY_CHARS and capped.endswith("...")
# Chunks past the cap must not change or grow the stored preview.
monitor.append_reply(entry_id, "y" * 1000)
assert monitor.snapshot()[0]["reply"] == capped
def test_api_monitor_append_reply_exact_cap_then_more_marks_truncated():
import core.inference.api_monitor as m
monitor = ApiMonitor(max_entries = 1)
entry_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "m",
prompt = "go",
)
# A reply landing exactly on the cap has no "..." marker yet.
monitor.append_reply(entry_id, "x" * m._MAX_REPLY_CHARS)
assert not monitor.snapshot()[0]["reply"].endswith("...")
# One more chunk must record the truncation, not silently freeze.
monitor.append_reply(entry_id, "y")
reply = monitor.snapshot()[0]["reply"]
assert len(reply) == m._MAX_REPLY_CHARS and reply.endswith("...")

View file

@ -0,0 +1,84 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""_model_json_response produces the same body as JSONResponse(model.model_dump())."""
import asyncio
import json
from typing import Optional
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import routes.inference as inference_route
from core.inference import llama_http
class _Usage(BaseModel):
prompt_tokens: int = 3
completion_tokens: int = 5
details: Optional[dict] = None
class _Choice(BaseModel):
index: int = 0
text: str = "hello"
logprobs: Optional[dict] = None
class _Resp(BaseModel):
id: str = "chatcmpl-abc"
object: str = "chat.completion"
created: int = 1700000000
model: str = "unsloth/SmolLM2-135M-Instruct-GGUF"
choices: list[_Choice] = [_Choice()]
usage: _Usage = _Usage()
system_fingerprint: Optional[str] = None
def _old_body(model) -> bytes:
# What the previous code emitted: dict -> Starlette json.dumps.
return JSONResponse(content = model.model_dump()).body
def test_body_matches_old_jsonresponse():
model = _Resp()
resp = inference_route._model_json_response(model)
# Same decoded JSON (key order is irrelevant once parsed), nulls preserved.
assert json.loads(resp.body) == json.loads(_old_body(model))
assert json.loads(resp.body)["system_fingerprint"] is None # null kept, not dropped
def test_media_type_and_status():
resp = inference_route._model_json_response(_Resp(), status_code = 200)
assert resp.media_type == "application/json"
assert resp.status_code == 200
err = inference_route._model_json_response(_Resp(), status_code = 503)
assert err.status_code == 503
def test_pooled_client_reused_within_loop_and_recreated_after_close():
async def _scenario():
a = llama_http.nonstreaming_client()
b = llama_http.nonstreaming_client()
assert a is b # reused within one loop
await llama_http.aclose()
assert a.is_closed
c = llama_http.nonstreaming_client() # must not return the closed client
assert c is not a and not c.is_closed
await llama_http.aclose()
asyncio.run(_scenario())
def test_pooled_client_is_per_event_loop():
clients = []
# Each asyncio.run uses a fresh loop; the pooled client must not leak across.
for _ in range(2):
async def _grab():
clients.append(llama_http.nonstreaming_client())
await llama_http.aclose()
asyncio.run(_grab())
assert clients[0] is not clients[1]

View file

@ -0,0 +1,152 @@
# 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 ``_estimate_compute_buffer_bytes``: it scales with ``--parallel``,
tensor exceeds pipeline, and it is a safe upper bound on the allocations measured
on real hardware (Qwen3.6-27B-MTP: parallel 1/2/4/8 -> 36/492/1388/3220 MiB single
GPU, ~600 MiB/device tensor). No GPU, subprocess, or GGUF I/O."""
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)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
sys.modules.setdefault("structlog", _structlog_stub)
# httpx -- only stub when the real library is missing. Unconditional stubbing
# shadows HTTPError/Response that huggingface_hub.errors imports at load time,
# silently breaking the transformers introspection tier in tests collected after
# this one (the stub leaks via sys.modules for the whole session).
try:
import httpx as _httpx_real # noqa: F401
except ImportError:
_httpx_stub = _types.ModuleType("httpx")
for _exc in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
"HTTPError",
"RequestError",
):
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
_httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
_httpx_stub.Response = type("Response", (), {})
_httpx_stub.Client = type(
"C",
(),
{
"__init__": lambda s, **kw: None,
"__enter__": lambda s: s,
"__exit__": lambda s, *a: None,
},
)
sys.modules["httpx"] = _httpx_stub
from core.inference.llama_cpp import LlamaCppBackend
MIB = 1024 * 1024
def _backend(vocab = 248320, embd = 5120):
"""Backend with just the dims the compute-buffer estimate reads."""
b = LlamaCppBackend.__new__(LlamaCppBackend)
b._vocab_size = vocab
b._embedding_length = embd
return b
# Measured ground truth (MiB) the estimate must upper-bound.
_PIPELINE_MEASURED = {1: 36, 2: 492, 4: 1388, 8: 3220}
_TENSOR_MEASURED_PER_DEVICE = 600
class TestSafeUpperBound:
"""The estimate must be >= every measured allocation (never under-reserve)."""
@pytest.mark.parametrize("parallel,measured", sorted(_PIPELINE_MEASURED.items()))
def test_pipeline_upper_bounds_measured(self, parallel, measured):
est = _backend()._estimate_compute_buffer_bytes(n_parallel = parallel) / MIB
assert est >= measured, f"under-reserved at parallel={parallel}: {est:.0f} < {measured}"
@pytest.mark.parametrize("parallel,measured", sorted(_PIPELINE_MEASURED.items()))
def test_pipeline_not_wildly_over(self, parallel, measured):
# Stay within ~2x of measured so we don't waste context (the point of
# replacing the flat reserve). parallel=1 is tiny in absolute terms.
est = _backend()._estimate_compute_buffer_bytes(n_parallel = parallel) / MIB
assert est <= max(measured * 2.0, 128)
def test_tensor_upper_bounds_measured(self):
est = _backend()._estimate_compute_buffer_bytes(n_parallel = 1, per_device_tensor = True) / MIB
assert est >= _TENSOR_MEASURED_PER_DEVICE
def test_tensor_far_below_old_flat_reserve(self):
# The whole point: deterministic estimate << flat 5120 for this model.
est = _backend()._estimate_compute_buffer_bytes(n_parallel = 1, per_device_tensor = True) / MIB
assert est < LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
class TestScaling:
def test_grows_with_serving_slots(self):
b = _backend()
vals = [b._estimate_compute_buffer_bytes(n_parallel = p) for p in (1, 2, 4, 8)]
assert vals == sorted(vals) and vals[0] < vals[-1]
def test_parallel_1_is_small(self):
# Single-token decode: a few tens of MiB, not gigabytes.
est = _backend()._estimate_compute_buffer_bytes(n_parallel = 1) / MIB
assert est < 128
def test_tensor_exceeds_pipeline_at_same_parallel(self):
b = _backend()
pipe = b._estimate_compute_buffer_bytes(n_parallel = 1)
tens = b._estimate_compute_buffer_bytes(n_parallel = 1, per_device_tensor = True)
assert tens > pipe
def test_scales_with_vocab(self):
small = _backend(vocab = 32000)._estimate_compute_buffer_bytes(n_parallel = 4)
big = _backend(vocab = 256000)._estimate_compute_buffer_bytes(n_parallel = 4)
assert big > small
def test_scales_with_ubatch(self):
b = _backend()
lo = b._estimate_compute_buffer_bytes(n_parallel = 4, n_ubatch = 256)
hi = b._estimate_compute_buffer_bytes(n_parallel = 4, n_ubatch = 1024)
assert hi > lo
class TestFallback:
def test_zero_when_vocab_missing(self):
assert _backend(vocab = None)._estimate_compute_buffer_bytes(n_parallel = 4) == 0
def test_zero_when_embd_missing(self):
assert _backend(embd = None)._estimate_compute_buffer_bytes(n_parallel = 4) == 0
def test_zero_lets_tensor_plan_use_flat_fallback(self):
# When dims are missing, _plan_tensor_parallel must fall back to the flat
# reserve (defense-in-depth) rather than reserving 0 and OOMing.
b = _backend(vocab = None, embd = None)
b._n_layers = None # can't estimate KV -> floors ctx, still returns a plan
ec, mac, gi, ts = b._plan_tensor_parallel([(0, 48000), (1, 48000)], 8 * 1024**3, 8192)
assert gi == [0, 1] # both GPUs usable under the flat fallback
class TestParallel1Default:
"""At Studio's default --parallel 1 the buffer is negligible in pipeline."""
def test_default_n_parallel(self):
est = _backend()._estimate_compute_buffer_bytes() / MIB
assert est < 128

View file

@ -13,6 +13,7 @@ from typing import Iterable, Mapping
from utils.models.gguf_metadata import (
is_mmproj_by_metadata,
pairing_score,
read_gguf_context_length,
read_gguf_general_metadata,
read_mmproj_audio_capability,
)
@ -21,6 +22,7 @@ from utils.models.gguf_metadata import (
_GGUF_MAGIC = 0x46554747
_VTYPE_STRING = 8
_VTYPE_UINT32 = 4
_VTYPE_UINT64 = 10
_VTYPE_ARRAY = 9
_VTYPE_BOOL = 7
@ -38,6 +40,10 @@ def _enc_kv_uint32(key: str, value: int) -> bytes:
return _enc_string(key) + struct.pack("<I", _VTYPE_UINT32) + struct.pack("<I", value)
def _enc_kv_uint64(key: str, value: int) -> bytes:
return _enc_string(key) + struct.pack("<I", _VTYPE_UINT64) + struct.pack("<Q", value)
def _enc_kv_bool(key: str, value: bool) -> bytes:
return _enc_string(key) + struct.pack("<I", _VTYPE_BOOL) + struct.pack("<B", 1 if value else 0)
@ -56,21 +62,29 @@ def _write_synthetic_gguf(
general_strings: Mapping[str, str],
*,
extra_uint32: Mapping[str, int] | None = None,
extra_uint64: Mapping[str, int] | None = None,
extra_string_arrays: Mapping[str, Iterable[str]] | None = None,
extra_bools: Mapping[str, bool] | None = None,
) -> Path:
"""Minimal GGUF: header + KV body, no tensors."""
extra_uint32 = extra_uint32 or {}
extra_uint64 = extra_uint64 or {}
extra_string_arrays = extra_string_arrays or {}
extra_bools = extra_bools or {}
kv_count = (
len(general_strings) + len(extra_uint32) + len(extra_string_arrays) + len(extra_bools)
len(general_strings)
+ len(extra_uint32)
+ len(extra_uint64)
+ len(extra_string_arrays)
+ len(extra_bools)
)
body = b""
for k, v in general_strings.items():
body += _enc_kv_string(k, v)
for k, v in extra_uint32.items():
body += _enc_kv_uint32(k, v)
for k, v in extra_uint64.items():
body += _enc_kv_uint64(k, v)
for k, v in extra_string_arrays.items():
body += _enc_kv_string_array(k, v)
for k, v in extra_bools.items():
@ -100,6 +114,66 @@ def test_returns_none_for_non_gguf(tmp_path: Path):
assert read_gguf_general_metadata(str(p)) is None
def test_context_length_none_for_missing_file(tmp_path: Path):
assert read_gguf_context_length(str(tmp_path / "nope.gguf")) is None
def test_context_length_none_for_non_gguf(tmp_path: Path):
p = tmp_path / "garbage.gguf"
p.write_bytes(b"not a gguf file at all, just bytes")
assert read_gguf_context_length(str(p)) is None
def test_context_length_read_from_arch_namespaced_key(tmp_path: Path):
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.architecture": "llama"},
extra_uint32 = {"llama.context_length": 4096, "llama.block_count": 32},
)
assert read_gguf_context_length(str(p)) == 4096
def test_context_length_none_when_absent(tmp_path: Path):
# Architecture present but no <arch>.context_length key.
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.architecture": "llama"},
extra_uint32 = {"llama.block_count": 32},
)
assert read_gguf_context_length(str(p)) is None
def test_context_length_ignores_foreign_arch_key(tmp_path: Path):
# A context_length under a different arch namespace must not match.
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.architecture": "llama"},
extra_uint32 = {"qwen2.context_length": 8192},
)
assert read_gguf_context_length(str(p)) is None
def test_context_length_read_from_uint64(tmp_path: Path):
# Some models store context_length as a uint64 (vtype 10).
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.architecture": "qwen3"},
extra_uint64 = {"qwen3.context_length": 262144},
)
assert read_gguf_context_length(str(p)) == 262144
def test_context_length_zero_treated_as_absent(tmp_path: Path):
# A zero/garbage ceiling must read as None so the UI can't build a slider
# with max < min.
p = _write_synthetic_gguf(
tmp_path / "model.gguf",
{"general.architecture": "llama"},
extra_uint32 = {"llama.context_length": 0},
)
assert read_gguf_context_length(str(p)) is None
def test_extracts_general_string_fields(tmp_path: Path):
p = _write_synthetic_gguf(
tmp_path / "model.gguf",

View file

@ -0,0 +1,160 @@
# 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: GGUF Chat-Mode downloads route through the Xet->HTTP helper,
preserving cancellation and the best-effort companion contract. No GPU, no
network, no real subprocess (the helper is patched).
"""
from __future__ import annotations
import sys
import threading
import types as _types
from pathlib import Path
from unittest.mock import patch
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Heavy-dep stubbing; prefer the real structlog so a bare stub never leaks to
# later modules that log at import time.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
try:
import structlog # noqa: F401
except ImportError:
sys.modules["structlog"] = _types.ModuleType("structlog")
try:
import httpx # noqa: F401
except ImportError:
_httpx_stub = _types.ModuleType("httpx")
for _exc in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
"HTTPError",
"RequestError",
"HTTPStatusError",
):
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
_httpx_stub.Response = type("Response", (), {})
_httpx_stub.Request = type("Request", (), {})
_httpx_stub.Timeout = type("Timeout", (), {"__init__": lambda self, *a, **k: None})
_httpx_stub.Client = type(
"Client",
(),
{
"__init__": lambda self, **k: None,
"__enter__": lambda self: self,
"__exit__": lambda self, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
from huggingface_hub import constants as hf_constants
from core.inference.llama_cpp import LlamaCppBackend
from utils.hf_xet_fallback import DownloadStallError
REPO = "unsloth/vision-GGUF"
@pytest.fixture
def hf_cache(tmp_path, monkeypatch):
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
return tmp_path
def _build_cache(
root: Path,
repo_id: str,
files: dict[str, int],
sha: str = "a" * 40,
) -> Path:
repo_dir = root / f"models--{repo_id.replace('/', '--')}"
(repo_dir / "blobs").mkdir(parents = True, exist_ok = True)
snap = repo_dir / "snapshots" / sha
snap.mkdir(parents = True, exist_ok = True)
for rel, size in files.items():
(snap / rel).write_bytes(b"\0" * size)
return snap
def test_companion_routes_through_helper(hf_cache):
_build_cache(hf_cache, REPO, {"mmproj-vision-F16.gguf": 1})
backend = LlamaCppBackend()
captured = {}
def fake_helper(
repo_id,
filename,
token = None,
**kwargs,
):
captured["filename"] = filename
captured["cancel_event"] = kwargs.get("cancel_event")
return f"/fake/{filename}"
with (
patch("huggingface_hub.list_repo_files", lambda *a, **k: ["mmproj-vision-F16.gguf"]),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_helper),
):
out = backend._download_mmproj(hf_repo = REPO, hf_token = None)
assert out == "/fake/mmproj-vision-F16.gguf"
# _cancel_event must be threaded through so /unload can abort the download.
assert captured["cancel_event"] is backend._cancel_event
def test_companion_swallows_terminal_stall_to_none(hf_cache):
_build_cache(hf_cache, REPO, {"mmproj-vision-F16.gguf": 1})
backend = LlamaCppBackend()
def stalling_helper(
repo_id,
filename,
token = None,
**kwargs,
):
raise DownloadStallError("both transports stalled")
with (
patch("huggingface_hub.list_repo_files", lambda *a, **k: ["mmproj-vision-F16.gguf"]),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", stalling_helper),
):
out = backend._download_mmproj(hf_repo = REPO, hf_token = None)
assert out is None, "a companion download is best-effort; a terminal stall must not raise"
def test_companion_cancelled_skips_download(hf_cache):
_build_cache(hf_cache, REPO, {"mmproj-vision-F16.gguf": 1})
backend = LlamaCppBackend()
backend._cancel_event.set()
called = {"n": 0}
def helper(
repo_id,
filename,
token = None,
**kwargs,
):
called["n"] += 1
return "/should-not-happen"
with (
patch("huggingface_hub.list_repo_files", lambda *a, **k: ["mmproj-vision-F16.gguf"]),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", helper),
):
out = backend._download_mmproj(hf_repo = REPO, hf_token = None)
assert out is None
assert called["n"] == 0, "a cancelled load must not start a companion download"

View file

@ -0,0 +1,352 @@
# 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 utils.hf_xet_fallback: the no-progress watchdog, the Xet->HTTP
transport policy, and the HF_HUB_DISABLE_XET precondition the fallback rests on.
CPU-only, no network, no real subprocess (the per-attempt download seam is
monkeypatched).
"""
from __future__ import annotations
import subprocess
import sys
import threading
import time
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/unavailable deps before importing the module under test. Use the
# real structlog when present; a bare stub left in sys.modules would break later
# modules that log at import time.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
try:
import structlog # noqa: F401
except ImportError:
sys.modules["structlog"] = _types.ModuleType("structlog")
import huggingface_hub
from huggingface_hub import constants as hf_constants
import utils.hf_xet_fallback as xf
# --------------------------------------------------------------------------- #
# Watchdog: fires only on a constant-size .incomplete, sparse-aware byte total.
# --------------------------------------------------------------------------- #
REPO = "ztest/xet-watchdog"
@pytest.fixture
def hf_cache(tmp_path, monkeypatch):
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
return tmp_path
def _blobs_dir(root: Path, repo_id: str = REPO) -> Path:
d = root / f"models--{repo_id.replace('/', '--')}" / "blobs"
d.mkdir(parents = True, exist_ok = True)
return d
def _wait(
predicate,
timeout: float = 2.0,
step: float = 0.02,
) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(step)
return predicate()
def test_constant_incomplete_fires_stall(hf_cache):
blobs = _blobs_dir(hf_cache)
(blobs / "deadbeef.incomplete").write_bytes(b"\0" * 1024) # never grows
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
)
try:
assert _wait(
lambda: len(calls) >= 1, timeout = 3.0
), "watchdog never fired on a constant-size .incomplete"
finally:
stop.set()
assert "stalled" in calls[0].lower()
def test_growing_incomplete_never_stalls(hf_cache):
blobs = _blobs_dir(hf_cache)
part = blobs / "growing.incomplete"
part.write_bytes(b"\0" * 1024)
grow_stop = threading.Event()
def _grow():
size = 1024
while not grow_stop.wait(0.05):
size += 4096
part.write_bytes(b"\0" * size)
grower = threading.Thread(target = _grow, daemon = True)
grower.start()
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
)
try:
time.sleep(1.0) # well past stall_timeout, but bytes keep growing
assert calls == [], "watchdog fired despite continuous progress"
finally:
stop.set()
grow_stop.set()
def test_no_incomplete_never_stalls(hf_cache):
blobs = _blobs_dir(hf_cache)
(blobs / "finalized_blob").write_bytes(b"\0" * 4096) # no .incomplete
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
)
try:
time.sleep(0.8)
assert calls == [], "watchdog fired with no active .incomplete"
finally:
stop.set()
def test_stall_fires_at_most_once(hf_cache):
blobs = _blobs_dir(hf_cache)
(blobs / "frozen.incomplete").write_bytes(b"\0" * 2048)
calls: list[str] = []
stop = xf.start_watchdog(
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.2
)
try:
assert _wait(lambda: len(calls) >= 1, timeout = 3.0)
time.sleep(0.6) # keep ticking; must not fire again
assert len(calls) == 1, f"on_stall fired {len(calls)} times, expected exactly 1"
finally:
stop.set()
def test_get_state_empty_cache(hf_cache):
assert xf.get_hf_download_state([REPO]) == (0, False)
def test_get_state_absent_cache_root(tmp_path, monkeypatch):
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path / "no-such-cache"))
assert xf.get_hf_download_state([REPO]) == (0, False)
def test_get_state_skips_local_paths(hf_cache):
# Filesystem paths are not HF repo IDs and must be ignored without error.
assert xf.get_hf_download_state(["/abs/path", "./rel", "~user", "c:\\x"]) == (0, False)
def test_get_state_sparse_aware(hf_cache):
blobs = _blobs_dir(hf_cache)
sparse = blobs / "sparse.incomplete"
with open(sparse, "wb") as f:
f.truncate(64 * 1024 * 1024) # large apparent size, few allocated blocks
st = sparse.stat()
if getattr(st, "st_blocks", 0) == 0:
pytest.skip("filesystem does not report st_blocks; sparse accounting unavailable")
total, has_incomplete = xf.get_hf_download_state([REPO])
assert has_incomplete is True
assert total < st.st_size, "sparse partial counted at apparent size, not allocated blocks"
# --------------------------------------------------------------------------- #
# Transport policy: cached short-circuit, cancel, error propagation, and the
# single Xet->HTTP fallback. _run_download_attempt is faked, so no real spawn.
# --------------------------------------------------------------------------- #
DL_REPO, FILE = "ztest/xet-dl", "model-Q4_K_XL.gguf"
@pytest.fixture(autouse = True)
def _no_real_cache_hit(monkeypatch):
"""Default: the cached probe misses; tests override it to force a hit."""
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: None)
class _FakeAttempt:
"""Records calls to the download seam and returns scripted results."""
def __init__(self, results):
self._results = list(results)
self.calls = []
def __call__(
self,
repo_id,
filename,
token,
*,
repo_type,
disable_xet,
cancel_event,
stall_timeout,
interval,
grace_period,
on_status,
):
self.calls.append(
_types.SimpleNamespace(
repo_id = repo_id,
filename = filename,
disable_xet = disable_xet,
repo_type = repo_type,
)
)
return self._results[len(self.calls) - 1]
def _install(monkeypatch, results):
fake = _FakeAttempt(results)
monkeypatch.setattr(xf, "_run_download_attempt", fake)
return fake
def test_cached_file_short_circuits(monkeypatch, tmp_path):
cached = tmp_path / "cached.gguf"
cached.write_bytes(b"\0" * 8)
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: str(cached))
fake = _install(monkeypatch, []) # must not be called
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert out == str(cached)
assert fake.calls == [], "spawned a download for an already-cached file"
def test_cancel_before_start_raises_no_attempt(monkeypatch):
fake = _install(monkeypatch, [])
ev = threading.Event()
ev.set()
with pytest.raises(RuntimeError, match = "Cancelled"):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None, cancel_event = ev)
assert fake.calls == []
def test_nonstall_error_propagates_without_fallback(monkeypatch):
fake = _install(monkeypatch, [("error", "RepositoryNotFoundError: 404 not found")])
with pytest.raises(RuntimeError, match = "RepositoryNotFoundError"):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert len(fake.calls) == 1, "deterministic error must not trigger an HTTP fallback"
assert fake.calls[0].disable_xet is False
def test_immediate_success_uses_xet_only(monkeypatch):
prepared = []
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport",
lambda *a, **k: prepared.append(a),
)
fake = _install(monkeypatch, [("ok", "/cache/model.gguf")])
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert out == "/cache/model.gguf"
assert len(fake.calls) == 1 and fake.calls[0].disable_xet is False
assert prepared == [], "no cache prep should run when Xet succeeds first try"
def test_stall_then_http_fallback_succeeds(monkeypatch):
prepared = []
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport",
lambda repo_type, repo_id, mode, *a, **k: prepared.append((repo_type, repo_id, mode)),
)
fake = _install(monkeypatch, [("stall", None), ("ok", "/cache/model.gguf")])
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert out == "/cache/model.gguf"
assert len(fake.calls) == 2
assert fake.calls[0].disable_xet is False # Xet first
assert fake.calls[1].disable_xet is True # HTTP fallback
assert prepared == [("model", DL_REPO, "http")], "must prep cache for HTTP before the retry"
def test_second_stall_raises_download_stall_error(monkeypatch):
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport", lambda *a, **k: None
)
fake = _install(monkeypatch, [("stall", None), ("stall", None)])
with pytest.raises(xf.DownloadStallError):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert len(fake.calls) == 2
def test_cancelled_midattempt_raises_no_fallback(monkeypatch):
fake = _install(monkeypatch, [("cancelled", None)])
with pytest.raises(RuntimeError, match = "Cancelled"):
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert len(fake.calls) == 1
def test_per_file_independent_fallback(monkeypatch):
"""A stalled shard falls back; a sibling shard that succeeds does not."""
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport", lambda *a, **k: None
)
fake = _install(monkeypatch, [("ok", "/a"), ("stall", None), ("ok", "/b")])
assert xf.hf_hub_download_with_xet_fallback(DL_REPO, "shardA.gguf", None) == "/a"
assert xf.hf_hub_download_with_xet_fallback(DL_REPO, "shardB.gguf", None) == "/b"
assert [c.disable_xet for c in fake.calls] == [False, False, True]
# --------------------------------------------------------------------------- #
# Precondition: HF_HUB_DISABLE_XET is read at import time, so assert its effect
# in a FRESH interpreter (huggingface/huggingface_hub#3266 once ignored it).
# --------------------------------------------------------------------------- #
def _safe_path() -> str:
import os
return os.environ.get("PATH", "")
def test_disable_xet_constant_set_in_fresh_interpreter():
code = (
"from huggingface_hub import constants as c; "
"import sys; sys.exit(0 if c.HF_HUB_DISABLE_XET is True else 17)"
)
proc = subprocess.run(
[sys.executable, "-c", code],
env = {"HF_HUB_DISABLE_XET": "1", "PATH": _safe_path()},
capture_output = True,
text = True,
)
assert proc.returncode == 0, (
f"HF_HUB_DISABLE_XET=1 did not set constants.HF_HUB_DISABLE_XET=True "
f"(rc={proc.returncode}): {proc.stderr}"
)
def test_default_leaves_xet_enabled():
code = (
"from huggingface_hub import constants as c; "
"import sys; sys.exit(0 if c.HF_HUB_DISABLE_XET is False else 17)"
)
proc = subprocess.run(
[sys.executable, "-c", code],
env = {"PATH": _safe_path()}, # no HF_HUB_DISABLE_XET
capture_output = True,
text = True,
)
assert proc.returncode == 0, (
f"without the env var, constants.HF_HUB_DISABLE_XET was not False "
f"(rc={proc.returncode}): {proc.stderr}"
)

View file

@ -71,7 +71,7 @@ except ImportError:
)
sys.modules["httpx"] = _httpx_stub
from core.inference.llama_cpp import LlamaCppBackend
from core.inference.llama_cpp import _CTX_FIT_VRAM_FRACTION, LlamaCppBackend
# Helpers
@ -1484,8 +1484,8 @@ class TestServerFlags:
assert fitted < 32_768
def test_fit_mtp_engaged_returns_smaller_or_equal_context(self):
# MTP budget is 0.85 of available, non-MTP is 0.90; on a tight
# budget MTP must yield <= non-MTP.
# Flat MTP fallback budget is _CTX_FIT_VRAM_FRACTION - 0.05; non-MTP is
# the full fraction. On a tight budget MTP must yield <= non-MTP.
b = self._gqa_backend()
common = dict(
requested_ctx = 32_768,
@ -1518,7 +1518,7 @@ class TestServerFlags:
kv_full = b._estimate_kv_cache_bytes(ctx, "f16", swa_full = True)
assert kv_full > kv_default
# Budget = model + kv_default (rounded up) -- swa_full must not fit.
budget_mib = (1024 * 1024 + kv_default) / (1024 * 1024) / 0.90 + 1
budget_mib = (1024 * 1024 + kv_default) / (1024 * 1024) / _CTX_FIT_VRAM_FRACTION + 1
fitted_default = b._fit_context_to_vram(
requested_ctx = ctx,
available_mib = int(budget_mib),

View file

@ -67,7 +67,11 @@ _httpx_stub.Client = type(
)
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend, classify_gpu_offload_lines
from core.inference.llama_cpp import (
_CTX_FIT_VRAM_FRACTION,
LlamaCppBackend,
classify_gpu_offload_lines,
)
from core.inference.llama_server_args import parse_ctx_override, resolve_requested_ctx
@ -171,7 +175,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 * _CTX_FIT_VRAM_FRACTION:
best_cap = max(best_cap, capped)
if best_cap > 0:
max_available_ctx = best_cap
@ -664,3 +668,39 @@ class TestClassifyGpuOffload:
def test_module_level_no_signal_returns_none(self):
assert classify_gpu_offload_lines(["INFO starting server"]) is None
def test_select_gpus_ranks_by_usable_not_raw_free():
# 80 GB card (30 GB free -> 25.9 GB usable) vs 32 GB card (29 GB free -> 27.4
# GB usable). A 27 GB model fits the 32 GB card alone; raw-free ranking would
# try the 80 GB card first and split across both. Usable ranking picks [1].
gpus = [(0, 30000), (1, 29000)]
totals = {0: 81920, 1: 32607}
model = int(27000 * 1024 * 1024)
idxs, use_fit = LlamaCppBackend._select_gpus(model, gpus, total_by_idx = totals)
assert idxs == [1] and use_fit is False
def test_select_gpus_reserves_per_device_overhead():
# Two 16 GB cards, ~15181 MiB usable each at 0.95 -> 30362 MiB pooled. A 30000
# MiB model fits the pool with no per-device overhead, but a layer split also
# pays ~1 GiB/extra-GPU; that pushes the 2-GPU need to 31024 MiB > pool, so a
# pin would OOM -> must fall back to --fit. Single-GPU fits add no overhead
# (Finding F1, the explicit/file-size multi-GPU pin gap).
gpus = [(0, 16000), (1, 16000)]
totals = {0: 16384, 1: 16384}
gib = 1024 * 1024 * 1024
model = int(30000 * 1024 * 1024)
idxs, use_fit = LlamaCppBackend._select_gpus(model, gpus, total_by_idx = totals)
assert idxs == [0, 1] and use_fit is False # fits 2 GPUs without overhead
idxs2, use_fit2 = LlamaCppBackend._select_gpus(
model, gpus, total_by_idx = totals, per_device_overhead_bytes = gib
)
assert idxs2 is None and use_fit2 is True # overhead tips it past the pool
# A single-GPU fit is unchanged by the overhead (k=1 adds nothing).
small = int(15000 * 1024 * 1024)
a, _ = LlamaCppBackend._select_gpus(small, gpus, total_by_idx = totals)
b, _ = LlamaCppBackend._select_gpus(
small, gpus, total_by_idx = totals, per_device_overhead_bytes = gib
)
assert a == [0] and b == [0]

View file

@ -520,3 +520,114 @@ def test_in_memory_only_reset_replays_stale_same_base_mix(monkeypatch, tmp_path)
assert info["latest_tag"] == "b9596-mix-aaa"
assert info["behind"] is True
assert info["stale"] is True
# update_download_size_bytes (banner download-size lookup).
def _patch_assets(monkeypatch, mapping):
"""Stub latest_release_assets with a per-repo {asset_name: size} lookup."""
monkeypatch.setattr(
fr,
"latest_release_assets",
lambda repo, *, force_refresh = False: mapping.get(repo),
)
def test_update_size_unsloth_prebuilt_exact_match(monkeypatch):
# The unsloth fork's own bundle (app-<tag>-<platform>): the want= exact match
# on app-<latest>-<suffix> wins.
marker = {
"asset": "app-b9190-linux-x64-cuda13-newer.tar.gz",
"published_repo": "unslothai/llama.cpp",
}
_patch_assets(
monkeypatch,
{
"unslothai/llama.cpp": {
"app-b9300-linux-x64-cuda13-newer.tar.gz": 123_456_789,
"app-b9300-windows-x64-cuda13-newer.zip": 999,
}
},
)
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 123_456_789
def test_update_size_macos_fork_asset_suffix_fallback(monkeypatch):
# macOS bundles use the upstream-style llama-<tag>-bin-macos-*, matched via the
# endswith fallback in the publish repo.
marker = {
"asset": "llama-b9190-bin-macos-arm64.tar.gz",
"published_repo": "unslothai/llama.cpp",
}
_patch_assets(
monkeypatch,
{"unslothai/llama.cpp": {"llama-b9300-bin-macos-arm64.tar.gz": 55_000_000}},
)
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 55_000_000
def test_update_size_upstream_ubuntu_uses_binary_repo(monkeypatch):
# #6338 P2: ggml-org ubuntu-* prebuilt lives in binary_repo, not the fork
# publish repo. The size must still resolve.
marker = {
"asset": "llama-b9190-bin-ubuntu-x64.tar.gz",
"published_repo": "unslothai/llama.cpp",
"binary_repo": "ggml-org/llama.cpp",
}
_patch_assets(
monkeypatch,
{
"unslothai/llama.cpp": {"app-b9300-linux-x64-cuda13-newer.tar.gz": 1},
"ggml-org/llama.cpp": {
"llama-b9673-bin-ubuntu-x64.tar.gz": 42_000_000,
"llama-b9673-bin-ubuntu-vulkan-x64.tar.gz": 7,
},
},
)
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 42_000_000
def test_update_size_upstream_windows_uses_binary_repo(monkeypatch):
# Regression (#6338 P2): the Windows upstream CPU prebuilt uses a win-* token.
marker = {
"asset": "llama-b9190-bin-win-cpu-x64.zip",
"published_repo": "unslothai/llama.cpp",
"binary_repo": "ggml-org/llama.cpp",
}
_patch_assets(
monkeypatch,
{"ggml-org/llama.cpp": {"llama-b9673-bin-win-cpu-x64.zip": 33_000_000}},
)
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") == 33_000_000
def test_update_size_no_matching_asset_fails_open(monkeypatch):
# A ROCm version drift (installed 6.4 vs latest 7.2) leaves no suffix match;
# the helper fails open to None rather than guessing a wrong artifact.
marker = {
"asset": "llama-b9190-bin-ubuntu-rocm-6.4-x64.tar.gz",
"published_repo": "unslothai/llama.cpp",
"binary_repo": "ggml-org/llama.cpp",
}
_patch_assets(
monkeypatch,
{"ggml-org/llama.cpp": {"llama-b9673-bin-ubuntu-rocm-7.2-x64.tar.gz": 9}},
)
assert fr.update_download_size_bytes(marker, "b9300", "unslothai/llama.cpp") is None
def test_update_size_missing_inputs_fail_open(monkeypatch):
_patch_assets(
monkeypatch,
{"unslothai/llama.cpp": {"app-b9300-linux-x64-cpu.tar.gz": 5}},
)
# No marker, no latest tag, or no asset string -> None (never raise).
assert fr.update_download_size_bytes(None, "b9300", "unslothai/llama.cpp") is None
assert (
fr.update_download_size_bytes(
{"asset": "app-b9190-linux-x64-cpu.tar.gz"}, None, "unslothai/llama.cpp"
)
is None
)
assert fr.update_download_size_bytes({"asset": None}, "b9300", "unslothai/llama.cpp") is None

View file

@ -74,7 +74,7 @@ _httpx_stub.Client = type(
)
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend
from core.inference.llama_cpp import _CTX_FIT_VRAM_FRACTION, LlamaCppBackend
# Helpers
@ -140,7 +140,7 @@ def _compute_max_available_ctx(
)
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 * _CTX_FIT_VRAM_FRACTION:
best_cap = max(best_cap, capped)
if best_cap > 0:
max_available_ctx = best_cap

View file

@ -1268,9 +1268,10 @@ def test_build_speculative_flags_user_draft_n_max_override(monkeypatch):
assert backend.spec_draft_n_max == 5
def test_build_speculative_flags_mtp_token_missing_logs_and_skips(monkeypatch):
# Outdated llama-server with no MTP support: forced MTP must degrade
# to spec-off (warned) rather than emit a bad --spec-type.
def test_build_speculative_flags_mtp_token_missing_emits_spec_default(monkeypatch):
# Outdated llama-server with no MTP support: forced MTP must degrade (warned)
# and emit --spec-default so an inherited LLAMA_ARG_SPEC_TYPE=draft-mtp (CLI
# wins over env) can't make the child attempt MTP the gate budgeted off.
backend = _resolver_backend(monkeypatch, mtp_token = None)
flags = backend._build_speculative_flags(
speculative_type = "mtp",
@ -1282,10 +1283,11 @@ def test_build_speculative_flags_mtp_token_missing_logs_and_skips(monkeypatch):
binary = "/fake/llama-server",
)
assert "--spec-type" not in flags
# _speculative_type stays None (resolved emission was none); the user's
# choice is still reflected in _requested_spec_mode.
assert "--spec-default" in flags
# Degraded to non-speculative; the user's choice is still reflected.
assert backend.speculative_type == "default"
assert backend.requested_spec_mode == "mtp"
assert backend.speculative_type is None
assert backend.spec_fallback_reason == "binary_no_mtp"
def test_forced_mtp_on_non_mtp_model_defaults_back(monkeypatch):

View file

@ -901,3 +901,49 @@ def test_start_update_marked_refuses_when_not_behind(monkeypatch, tmp_path):
res = upd.start_update()
assert res["started"] is False
assert res["reason"] == "up_to_date"
def test_status_update_available_includes_size(monkeypatch, tmp_path):
# Marker (prebuilt) update path attaches the download size of the asset the
# banner would fetch.
binary = _write_install(tmp_path, "b9493", asset = "app-b9493-linux-x64-cuda13-newer.tar.gz")
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
monkeypatch.setattr(
freshness,
"latest_release_assets",
lambda repo, *, force_refresh = False: {
"app-b9518-linux-x64-cuda13-newer.tar.gz": 88_000_000
},
)
st = upd.get_update_status(force_refresh = True)
assert st["update_available"] is True
assert st["update_size_bytes"] == 88_000_000
def test_status_source_build_includes_update_size(monkeypatch, tmp_path):
# #6338 P3: a source build offered a prebuilt must carry the asset size too.
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
binary.parent.mkdir(parents = True)
binary.write_text("stub") # no marker -> source build
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
_prebuilt(
monkeypatch,
repo = "unslothai/llama.cpp",
release_tag = "b9585",
asset = "app-b9585-linux-x64-cpu.tar.gz",
)
monkeypatch.setattr(upd, "_installed_build_number", lambda b: None)
monkeypatch.setattr(
upd,
"latest_release_assets",
lambda repo, *, force_refresh = False: (
{"app-b9585-linux-x64-cpu.tar.gz": 77_000_000}
if repo == "unslothai/llama.cpp"
else None
),
)
st = upd.get_update_status()
assert st["source_build"] is True
assert st["update_available"] is True
assert st["update_size_bytes"] == 77_000_000

View file

@ -281,6 +281,7 @@ def test_kill_process_records_timestamp_on_actual_kill():
backend = LlamaCppBackend.__new__(LlamaCppBackend)
backend._process = None
backend._healthy = False
backend._stats_logger = None # _kill_process stops it in finally
backend._stdout_thread = None
backend._llama_log_fh = None
backend._last_kill_monotonic = 0.0
@ -308,6 +309,26 @@ def test_kill_process_records_timestamp_on_actual_kill():
assert before <= backend._last_kill_monotonic <= after
def test_kill_process_tolerates_partially_constructed_backend():
# Teardown must not AttributeError on a __new__-built backend that never ran
# __init__: _stats_logger / _stdout_thread / _llama_log_fh are left unset.
backend = LlamaCppBackend.__new__(LlamaCppBackend)
class _FakeProcess:
def terminate(self):
pass
def wait(self, timeout = None):
return 0
def kill(self):
pass
backend._process = _FakeProcess()
backend._kill_process()
assert backend._process is None
def test_helper_is_static_method_callable_off_class():
"""Pin the @staticmethod binding so call sites can invoke off the class."""
ctx, _state = _patch_probe([[]])

View file

@ -78,6 +78,27 @@ def test_status_response_exposes_source_build():
rl.LlamaUpdateStatusResponse(**{**payload, "unexpected": 1})
def test_status_response_exposes_update_size_bytes():
payload = {
"supported": True,
"update_available": True,
"stale": False,
"installed_tag": "b9493",
"latest_tag": "b9518",
"published_repo": "unslothai/llama.cpp",
"installed_at_utc": None,
"age_days": None,
"source_build": False,
"update_size_bytes": 123_456_789,
"job": {"state": "idle"},
}
model = rl.LlamaUpdateStatusResponse(**payload)
assert model.model_dump()["update_size_bytes"] == 123_456_789
# Omitted -> defaults to None (the offline / no-matching-asset case).
without = {k: v for k, v in payload.items() if k != "update_size_bytes"}
assert rl.LlamaUpdateStatusResponse(**without).model_dump()["update_size_bytes"] is None
def test_status_handler_runs_off_event_loop(monkeypatch):
seen = {}

View file

@ -24,6 +24,7 @@ _lsa = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_lsa)
is_managed_flag = _lsa.is_managed_flag
parse_cache_override = _lsa.parse_cache_override
parse_cache_override_per_axis = _lsa.parse_cache_override_per_axis
parse_ctx_override = _lsa.parse_ctx_override
parse_split_mode_override = _lsa.parse_split_mode_override
resolve_cache_type_kv = _lsa.resolve_cache_type_kv
@ -467,6 +468,25 @@ def test_parse_cache_override_rejects_malformed_values(args):
parse_cache_override(args)
@pytest.mark.parametrize(
"args, expected",
[
(["--cache-type-k", "f32", "--cache-type-v", "f16"], ("f32", "f16")),
(["-ctk", "q8_0", "-ctv", "q4_0"], ("q8_0", "q4_0")),
(["--cache-type-k=f32"], ("f32", None)),
(["--cache-type-v", "f16"], (None, "f16")),
(["-c", "4096"], (None, None)),
(None, (None, None)),
# Last-wins is kept per axis.
(["-ctk", "f16", "-ctk", "f32"], ("f32", None)),
],
)
def test_parse_cache_override_per_axis(args, expected):
# Unlike parse_cache_override (collapses both axes to one last-wins value),
# this keeps K and V apart so an asymmetric cache can be budgeted per axis.
assert parse_cache_override_per_axis(args) == expected
def test_resolve_cache_type_kv_uses_override_when_present():
assert resolve_cache_type_kv(["--cache-type-k", "q8_0"], "f16") == "q8_0"
@ -649,6 +669,53 @@ def test_strip_shadowing_flags_drops_model_draft_with_spec():
assert out == ["--top-k", "20"]
@pytest.mark.parametrize(
"selector",
[
["--spec-draft-hf", "org/repo"],
["-hfd", "org/repo"],
["-hfrd", "org/repo"],
["--hf-repo-draft", "org/repo"],
["--spec-draft-hf=org/repo"],
],
)
def test_strip_shadowing_flags_drops_hf_drafter_selectors_with_spec(selector):
# HF drafter selectors must reset on inherit like local --model-draft, or a
# stale inherited HF drafter last-wins over Studio's re-derived spec choice.
out = strip_shadowing_flags(
selector + ["--top-k", "20"],
strip_context = False,
strip_cache = False,
strip_spec = True,
strip_template = False,
)
assert out == ["--top-k", "20"]
def test_strip_shadowing_flags_keeps_draft_tuning_with_spec():
# Per-drafter tuning knobs are deliberately preserved: the VRAM budget reads
# them via the same parsers the child honors (so they stay consistent on
# inherit), and stripping --spec-draft-ngl would move a CPU drafter to GPU.
keep = [
"--spec-draft-type-k",
"q4_0",
"--spec-draft-type-v",
"q4_0",
"--spec-draft-ngl",
"0",
"--spec-draft-device",
"cpu",
]
out = strip_shadowing_flags(
list(keep),
strip_context = False,
strip_cache = False,
strip_spec = True,
strip_template = False,
)
assert out == keep
def test_strip_shadowing_flags_keeps_split_mode_when_not_requested():
# No tensor_parallel field supplied on the Apply -> an inherited
# --split-mode survives (mirrors the chat-template keep behavior).

View file

@ -0,0 +1,128 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the llama-server /metrics -> engine_stats translator: generation
throughput comes from generated-token metrics (not llama_decode() calls), and
the unexposed kv_cache_usage_ratio is never fabricated into the log line."""
from core.inference.llama_stats import LlamaServerStatsLogger
class _Capture:
def __init__(self):
self.events = []
def info(self, event, **kw):
self.events.append((event, dict(kw)))
def debug(self, *a, **k):
pass
def _drive(snaps):
"""Run _run() synchronously over `snaps`, then stop deterministically."""
cap = _Capture()
lg = LlamaServerStatsLogger("http://127.0.0.1:0", cap)
lg._interval = 0.001 # bypass the 1s floor for a fast, synchronous run
state = {"i": 0}
def fake_scrape():
i = state["i"]
state["i"] += 1
if i >= len(snaps):
lg.stop()
return None
return snaps[i]
lg._scrape = fake_scrape
lg._run()
return [kw for ev, kw in cap.events if ev == "engine_stats"]
def test_gen_tok_s_uses_token_metrics_not_decode_calls():
# tokens_predicted_total jumps 95 while n_decode_total only moves 9; the
# gauge reports 95 tok/s. Decode-call rate (9) must not be reported.
snaps = [
{
"tokens_predicted_total": 0.0,
"prompt_tokens_total": 0.0,
"n_decode_total": 0.0,
"predicted_tokens_seconds": 95.0,
"prompt_tokens_seconds": 30.0,
"requests_processing": 1.0,
},
{
"tokens_predicted_total": 95.0,
"prompt_tokens_total": 30.0,
"n_decode_total": 9.0,
"predicted_tokens_seconds": 95.0,
"prompt_tokens_seconds": 30.0,
"requests_processing": 1.0,
},
]
stats = _drive(snaps)
assert stats, "expected engine_stats while a request is processing"
assert all(s["gen_tok_s"] == 95.0 for s in stats)
assert all(s["prompt_tok_s"] == 30.0 for s in stats)
def test_kv_cache_pct_not_emitted_when_metric_absent():
# llama.cpp does not expose kv_cache_usage_ratio, so it must not appear.
snaps = [
{
"tokens_predicted_total": 0.0,
"prompt_tokens_total": 0.0,
"predicted_tokens_seconds": 10.0,
"requests_processing": 1.0,
},
{
"tokens_predicted_total": 10.0,
"prompt_tokens_total": 5.0,
"predicted_tokens_seconds": 10.0,
"requests_processing": 1.0,
},
]
stats = _drive(snaps)
assert stats
assert all("kv_cache_pct" not in s for s in stats)
def test_scrape_parses_labelled_and_bare_metrics(monkeypatch):
# Prometheus samples may carry labels; both labelled and bare lines parse.
import core.inference.llama_stats as ls
body = (
'llamacpp:tokens_predicted_total{model="m"} 20\n'
'llamacpp:prompt_tokens_total{model="m"} 5\n'
"llamacpp:requests_processing 1\n"
"# HELP llamacpp:ignored ignored\n"
)
class _Resp:
status = 200
def read(self):
return body.encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(ls.urllib.request, "urlopen", lambda *a, **k: _Resp())
m = ls.LlamaServerStatsLogger("http://127.0.0.1:0", _Capture())._scrape()
assert m["tokens_predicted_total"] == 20.0
assert m["prompt_tokens_total"] == 5.0
assert m["requests_processing"] == 1.0
def test_counter_delta_fallback_without_gauges():
# Older binaries expose only the counters; throughput falls back to deltas.
snaps = [
{"tokens_predicted_total": 100.0, "prompt_tokens_total": 0.0, "requests_processing": 1.0},
{"tokens_predicted_total": 100.0, "prompt_tokens_total": 0.0, "requests_processing": 1.0},
]
stats = _drive(snaps)
# running=1 keeps it emitting; gen_tok_s falls back to the (here zero) delta.
assert stats and all(s["gen_tok_s"] >= 0.0 for s in stats)

View file

@ -123,6 +123,100 @@ def test_non_http_scope_passes_through(logs):
assert logs.events == []
def test_duplicate_get_within_window_deduped(logs, monkeypatch):
monkeypatch.setattr(hmod, "_ACCESS_LOG_DEDUP_MS", 1000)
async def app(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
async def send(message):
pass
mw = LoggingMiddleware(app)
for _ in range(3):
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send))
# Only the first of the identical GET/200 burst is logged.
assert len(logs.events) == 1
assert logs.events[0][1] == "request_completed"
def test_mutations_and_errors_are_never_deduped(logs, monkeypatch):
monkeypatch.setattr(hmod, "_ACCESS_LOG_DEDUP_MS", 1000)
async def post_ok(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
async def get_404(scope, receive, send):
await send({"type": "http.response.start", "status": 404, "headers": []})
await send({"type": "http.response.body", "body": b""})
async def send(message):
pass
mw = LoggingMiddleware(post_ok)
for _ in range(2):
_run(mw(_http_scope("/api/chat/threads", method = "POST"), _noop_receive, send))
mw_404 = LoggingMiddleware(get_404)
for _ in range(2):
_run(mw_404(_http_scope("/api/models"), _noop_receive, send))
# 2 mutations + 2 errors all logged (dedup only touches GET/2xx).
assert len(logs.events) == 4
def test_quiet_poll_paths_use_longer_heartbeat_window(logs, monkeypatch):
# Burst dedup off, quiet-poll heartbeat on: only liveness paths collapse.
monkeypatch.setattr(hmod, "_ACCESS_LOG_DEDUP_MS", 0)
monkeypatch.setattr(hmod, "_QUIET_POLL_DEDUP_MS", 1000)
async def app(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
async def send(message):
pass
mw = LoggingMiddleware(app)
for _ in range(3):
_run(mw(_http_scope("/api/inference/monitor"), _noop_receive, send)) # quiet
for _ in range(3):
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send)) # normal
paths = [e[2]["path"] for e in logs.events]
assert paths.count("/api/inference/monitor") == 1 # collapsed to one heartbeat
assert paths.count("/api/chat/projects") == 3 # base dedup off -> all logged
def test_distinct_query_strings_are_not_deduped(logs, monkeypatch):
monkeypatch.setattr(hmod, "_ACCESS_LOG_DEDUP_MS", 1000)
async def app(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
async def send(message):
pass
def scope(query):
return {
"type": "http",
"path": "/api/models/browse-folders",
"method": "GET",
"query_string": query,
}
mw = LoggingMiddleware(app)
_run(mw(scope(b"path=/tmp/a"), _noop_receive, send))
_run(mw(scope(b"path=/tmp/b"), _noop_receive, send)) # distinct query -> logs
_run(mw(scope(b"path=/tmp/a"), _noop_receive, send)) # repeat of first -> deduped
# Two distinct query strings log; the immediate repeat of the first does not.
assert len(logs.events) == 2
def test_fastapi_static_asset_success_skips_log(tmp_path, logs):
assets_dir = tmp_path / "assets"
assets_dir.mkdir()

View file

@ -293,6 +293,183 @@ class TestSecurityHeadersMiddleware:
# not read directive-string `in` membership as URL sanitisation.
assert any(src == "https:" for src in directives[name])
def test_headers_applied_to_streaming_response(self, main_module):
# The ASGI middleware must set headers on streaming responses too.
from fastapi.responses import StreamingResponse
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get("/stream")
async def stream():
async def gen():
yield b"a"
yield b"b"
return StreamingResponse(gen(), media_type = "text/plain")
r = TestClient(app).get("/stream")
assert r.status_code == 200
assert r.text == "ab"
assert r.headers["x-content-type-options"] == "nosniff"
assert r.headers["server"] == "unsloth-studio"
assert "content-security-policy" in r.headers
def test_artifact_preview_frame_omits_x_frame_options(self, main_module):
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get(main_module._ARTIFACT_PREVIEW_FRAME_PATH)
async def frame():
return Response(content = b"<html></html>", media_type = "text/html")
r = TestClient(app).get(main_module._ARTIFACT_PREVIEW_FRAME_PATH)
assert r.status_code == 200
assert "x-frame-options" not in {k.lower() for k in r.headers.keys()}
assert r.headers["referrer-policy"] == "no-referrer"
def test_response_start_with_tuple_headers_is_hardened(self, main_module):
# An ASGI server may emit tuple-valued raw headers; the middleware must
# coerce to a list and still inject security headers without crashing.
import asyncio
async def _inner_app(scope, receive, send):
await send(
{
"type": "http.response.start",
"status": 200,
"headers": ((b"content-type", b"text/plain"),), # tuple, not list
}
)
await send({"type": "http.response.body", "body": b"ok"})
captured = {}
async def _send(message):
if message["type"] == "http.response.start":
captured["headers"] = dict(message["headers"])
async def _receive():
return {"type": "http.request"}
mw = main_module.SecurityHeadersMiddleware(_inner_app)
asyncio.run(mw({"type": "http", "path": "/plain"}, _receive, _send))
hdrs = captured["headers"]
assert hdrs[b"server"] == b"unsloth-studio"
assert b"content-security-policy" in hdrs
assert hdrs[b"x-frame-options"] == b"DENY"
def test_is_pure_asgi_not_basehttp_middleware(self, main_module):
# Regression: as a BaseHTTPMiddleware this wrapped the SSE stream in its
# own anyio task group, breaking disconnect detection (GPU stuck at 100%)
# and raising cancel scope errors. Must stay pure ASGI.
from starlette.middleware.base import BaseHTTPMiddleware
cls = main_module.SecurityHeadersMiddleware
assert not issubclass(cls, BaseHTTPMiddleware)
assert not hasattr(cls, "dispatch")
def test_forwards_receive_channel_unchanged(self, main_module):
# Must forward the ASGI receive channel untouched so client disconnects
# reach the streaming handler (BaseHTTPMiddleware swapped in its own).
seen = {}
async def inner_app(scope, receive, send):
seen["receive"] = receive
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok", "more_body": False})
mw = main_module.SecurityHeadersMiddleware(inner_app)
sentinel_receive = object() # forwarded verbatim, never wrapped/awaited
sent = []
async def send(message):
sent.append(message)
async def run():
await mw(
{"type": "http", "path": "/plain", "headers": []},
sentinel_receive,
send,
)
asyncio.run(run())
assert seen["receive"] is sentinel_receive
start = next(m for m in sent if m["type"] == "http.response.start")
names = {n.lower() for n, _ in start["headers"]}
assert b"content-security-policy" in names
assert b"server" in names
def test_streaming_response_survives_client_disconnect(self, main_module):
# A StreamingResponse that polls is_disconnected() (like gguf_tool_stream)
# must unwind cleanly on client disconnect: no cancel scope error, the
# generator's finally runs, and security headers are still applied.
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
state = {"cleaned_up": False}
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get("/v1/chat/completions")
async def stream(request: Request):
async def gen():
try:
for i in range(1000):
if await request.is_disconnected():
break
yield f"data: {i}\n\n".encode()
await asyncio.sleep(0.01)
finally:
state["cleaned_up"] = True
return StreamingResponse(gen(), media_type = "text/event-stream")
scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"path": "/v1/chat/completions",
"raw_path": b"/v1/chat/completions",
"query_string": b"",
"root_path": "",
"scheme": "http",
"headers": [(b"host", b"testserver")],
"client": ("127.0.0.1", 50000),
"server": ("127.0.0.1", 80),
}
async def run():
body_started = asyncio.Event()
calls = {"n": 0}
async def receive():
calls["n"] += 1
if calls["n"] == 1:
return {"type": "http.request", "body": b"", "more_body": False}
await body_started.wait() # client clicks Stop after tokens stream
return {"type": "http.disconnect"}
sent = []
async def send(message):
sent.append(message)
if message["type"] == "http.response.body" and message.get("body"):
body_started.set()
# Must return without raising the anyio cancel-scope RuntimeError.
await asyncio.wait_for(app(scope, receive, send), timeout = 5.0)
return sent
sent = asyncio.run(run())
assert state["cleaned_up"] is True
start = next(m for m in sent if m["type"] == "http.response.start")
names = {n.lower() for n, _ in start["headers"]}
assert b"content-security-policy" in names
assert b"server" in names
# /api/health auth gate

File diff suppressed because it is too large Load diff

View file

@ -209,10 +209,10 @@ class TestGgufVariantFileResolution:
return [_types.SimpleNamespace(path = path, size = 1) for path in paths if path is not None]
def fake_download(
*,
repo_id,
filename,
token = None,
**_kwargs,
):
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
@ -229,7 +229,7 @@ class TestGgufVariantFileResolution:
),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("huggingface_hub.hf_hub_download", fake_download),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
):
out = backend._download_gguf(
hf_repo = "ggml-org/models",
@ -256,10 +256,10 @@ class TestGgufVariantFileResolution:
return [_types.SimpleNamespace(path = path, size = 1) for path in paths if path is not None]
def fake_download(
*,
repo_id,
filename,
token = None,
**_kwargs,
):
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
@ -269,7 +269,7 @@ class TestGgufVariantFileResolution:
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("huggingface_hub.hf_hub_download", fake_download),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
):
out = backend._download_gguf(
hf_repo = "org/repo",
@ -639,17 +639,20 @@ class TestDownloadMmprojOfflineCacheFallback:
raise OSError("offline")
def fake_download(
*,
repo_id,
filename,
token = None,
**kwargs,
):
# Echo back so the test can verify the cache-resolved filename
return f"/fake/cache/{repo_id}/{filename}"
with (
patch("huggingface_hub.list_repo_files", boom_list),
patch("huggingface_hub.hf_hub_download", fake_download),
patch(
"core.inference.llama_cpp.hf_hub_download_with_xet_fallback",
fake_download,
),
):
out = backend._download_mmproj(
hf_repo = "unsloth/vision-GGUF",
@ -675,17 +678,20 @@ class TestDownloadMmprojOfflineCacheFallback:
captured = {}
def fake_download(
*,
repo_id,
filename,
token = None,
**kwargs,
):
captured["filename"] = filename
return f"/fake/{filename}"
with (
patch("huggingface_hub.list_repo_files", boom_list),
patch("huggingface_hub.hf_hub_download", fake_download),
patch(
"core.inference.llama_cpp.hf_hub_download_with_xet_fallback",
fake_download,
),
):
backend._download_mmproj(
hf_repo = "unsloth/vision-GGUF",

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ from fastapi import HTTPException
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from core.inference.api_monitor import ApiMonitor
from models.inference import (
ChatMessage,
ResponsesFunctionCallInputItem,
@ -781,6 +782,129 @@ class TestResponsesNonStreamingAdapter:
assert "<think>" not in body["output"][1]["content"][0]["text"]
assert "</think>" not in body["output"][1]["content"][0]["text"]
def test_monitor_records_translated_visible_text(self, monkeypatch):
import routes.inference as inf_mod
async def fake_chat_completions(chat_req, request):
assert request.state.skip_api_monitor is True
return JSONResponse(
content = {
"model": "test-model",
"choices": [{"message": {"content": "<think>plan</think>answer"}}],
"usage": {"prompt_tokens": 2, "completion_tokens": 3},
}
)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "openai_chat_completions", fake_chat_completions)
payload = ResponsesRequest(input = "hi", reasoning = {"effort": "high"})
messages = [ChatMessage(role = "user", content = "hi")]
request = SimpleNamespace(
state = SimpleNamespace(),
url = SimpleNamespace(path = "/v1/responses"),
method = "POST",
)
async def run():
response = await _responses_non_streaming(payload, messages, request)
return json.loads(response.body.decode())
body = asyncio.run(run())
assert body["output"][0]["content"] == [{"type": "reasoning_text", "text": "plan"}]
assert body["output"][1]["content"][0]["text"] == "answer"
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "answer"
assert entry["prompt_tokens"] == 2
assert entry["completion_tokens"] == 3
assert request.state.skip_api_monitor is False
def test_monitor_records_tool_only_reply(self, monkeypatch):
import routes.inference as inf_mod
async def fake_chat_completions(chat_req, request):
assert request.state.skip_api_monitor is True
return JSONResponse(
content = {
"model": "test-model",
"choices": [
{
"message": {
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "lookup",
"arguments": '{"query":"weather"}',
},
}
],
}
}
],
"usage": {"prompt_tokens": 2, "completion_tokens": 3},
}
)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "openai_chat_completions", fake_chat_completions)
payload = ResponsesRequest(
input = "hi",
tools = [{"type": "function", "name": "lookup"}],
)
messages = [ChatMessage(role = "user", content = "hi")]
request = SimpleNamespace(
state = SimpleNamespace(),
url = SimpleNamespace(path = "/v1/responses"),
method = "POST",
)
async def run():
response = await _responses_non_streaming(payload, messages, request)
return json.loads(response.body.decode())
body = asyncio.run(run())
assert body["output"][0]["type"] == "function_call"
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == 'Tool call: lookup({"query":"weather"})'
assert request.state.skip_api_monitor is False
def test_cancelled_chat_completion_finalizes_monitor(self, monkeypatch):
import routes.inference as inf_mod
async def fake_chat_completions(chat_req, request):
assert request.state.skip_api_monitor is True
raise asyncio.CancelledError()
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "openai_chat_completions", fake_chat_completions)
payload = ResponsesRequest(input = "hi")
messages = [ChatMessage(role = "user", content = "hi")]
request = SimpleNamespace(
state = SimpleNamespace(),
url = SimpleNamespace(path = "/v1/responses"),
method = "POST",
)
async def run():
with pytest.raises(asyncio.CancelledError):
await _responses_non_streaming(payload, messages, request)
asyncio.run(run())
[entry] = monitor.snapshot()
assert entry["status"] == "cancelled"
assert monitor.active_count() == 0
assert request.state.skip_api_monitor is False
def test_literal_think_tags_remain_visible_without_reasoning_request(self, monkeypatch):
body = self._run_with_message(monkeypatch, {"content": "show <think>x</think> tags"})
@ -939,6 +1063,275 @@ class TestResponsesStreamAdapter:
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
assert completed["response"]["output"][1]["content"][0]["text"] == "33"
def test_usage_only_chunk_updates_monitor(self, monkeypatch):
import routes.inference as inf_mod
chunks = [
{"choices": [{"delta": {"content": "33"}}]},
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
]
self._install_stream_mock(monkeypatch, chunks)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monitor_id = monitor.start(
endpoint = "/v1/responses",
method = "POST",
model = "m",
prompt = "hi",
)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(
payload,
messages,
self._Request(),
monitor_id = monitor_id,
)
return await self._collect(response)
asyncio.run(run())
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "33"
assert entry["prompt_tokens"] == 2
assert entry["completion_tokens"] == 3
assert entry["total_tokens"] == 5
assert entry["context_length"] == 4096
def test_function_call_chunk_updates_monitor_reply(self, monkeypatch):
import routes.inference as inf_mod
chunks = [
{
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_1",
"function": {
"name": "lookup",
"arguments": '{"query":"weather"}',
},
}
]
}
}
]
}
]
self._install_stream_mock(monkeypatch, chunks)
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monitor_id = monitor.start(
endpoint = "/v1/responses",
method = "POST",
model = "m",
prompt = "hi",
)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(
payload,
messages,
self._Request(),
monitor_id = monitor_id,
)
return await self._collect(response)
lines = asyncio.run(run())
assert self._payloads(lines, "response.output_item.done")[-1]["item"]["name"] == "lookup"
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == 'Tool call: lookup({"query":"weather"})'
def test_preheader_cancel_finalizes_monitor(self, monkeypatch):
import routes.inference as inf_mod
self._install_stream_mock(monkeypatch, [])
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monitor_id = monitor.start(
endpoint = "/v1/responses",
method = "POST",
model = "m",
prompt = "hi",
)
async def fake_send(*_args, **_kwargs):
return None
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(
payload,
messages,
self._Request(),
monitor_id = monitor_id,
)
return await self._collect(response)
asyncio.run(run())
[entry] = monitor.snapshot()
assert entry["status"] == "cancelled"
assert monitor.active_count() == 0
def test_stream_task_cancel_finalizes_monitor(self, monkeypatch):
async def _run():
import routes.inference as inf_mod
async def fake_send(*_args, **_kwargs):
return httpx.Response(200, content = b"")
async def fake_items(*_args, **_kwargs):
yield 'data: {"choices":[{"delta":{"content":"hello"}}]}'
await asyncio.sleep(3600)
self._install_stream_mock(monkeypatch, [])
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
monitor_id = monitor.start(
endpoint = "/v1/responses",
method = "POST",
model = "m",
prompt = "hi",
)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
response = await _responses_stream(
payload,
messages,
self._Request(),
monitor_id = monitor_id,
)
iterator = response.body_iterator
first = ""
for _ in range(8):
first = await anext(iterator)
if "hello" in first:
break
else:
pytest.fail("stream did not emit text delta")
pending = asyncio.create_task(anext(iterator))
await asyncio.sleep(0)
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
[entry] = monitor.snapshot()
assert entry["status"] == "cancelled"
assert entry["reply"] == "hello"
assert monitor.active_count() == 0
asyncio.run(_run())
def test_final_visible_text_updates_monitor(self, monkeypatch):
import routes.inference as inf_mod
class FakeExtractor:
def __init__(self, **_kwargs):
pass
def feed(
self,
_content,
_reasoning_content = None,
):
return "", ""
def finish(self):
return "", "tail"
self._install_stream_mock(monkeypatch, [{"choices": [{"delta": {"content": "<tai"}}]}])
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "_ResponsesReasoningExtractor", FakeExtractor)
monitor_id = monitor.start(
endpoint = "/v1/responses",
method = "POST",
model = "m",
prompt = "hi",
)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(
payload,
messages,
self._Request(),
monitor_id = monitor_id,
)
return await self._collect(response)
lines = asyncio.run(run())
assert self._payloads(lines, "response.output_text.delta")[-1]["delta"] == "tail"
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "tail"
def test_reasoning_only_fallback_updates_monitor(self, monkeypatch):
import routes.inference as inf_mod
class FakeExtractor:
def __init__(self, **_kwargs):
pass
def feed(
self,
_content,
_reasoning_content = None,
):
return "", ""
def finish(self):
return "plan", ""
self._install_stream_mock(monkeypatch, [{"choices": [{"delta": {"content": "<think>"}}]}])
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "_ResponsesReasoningExtractor", FakeExtractor)
monitor_id = monitor.start(
endpoint = "/v1/responses",
method = "POST",
model = "m",
prompt = "hi",
)
payload = ResponsesRequest(input = "hi", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
async def run():
response = await _responses_stream(
payload,
messages,
self._Request(),
monitor_id = monitor_id,
)
return await self._collect(response)
lines = asyncio.run(run())
assert self._payloads(lines, "response.output_text.delta")[-1]["delta"] == "plan"
[entry] = monitor.snapshot()
assert entry["status"] == "completed"
assert entry["reply"] == "plan"
def test_literal_think_tags_stream_as_visible_text_without_reasoning_request(self, monkeypatch):
chunks = [
{"choices": [{"delta": {"content": "show <thi"}}]},

View file

@ -62,8 +62,11 @@ _httpx_stub.Client = type(
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference import llama_cpp as llama_cpp_module
from core.inference.llama_cpp import LlamaCppBackend
from core.inference.llama_server_args import resolve_tensor_parallel
from core.inference.llama_cpp import _CTX_FIT_VRAM_FRACTION, LlamaCppBackend
from core.inference.llama_server_args import (
_effective_tensor_parallel,
resolve_tensor_parallel,
)
from core.inference.tensor_fallback import load_with_tensor_fallback
from models.inference import (
InferenceStatusResponse,
@ -330,16 +333,21 @@ def _plan(
def _kv_budget_b(model_gb, gpus = _ASYM):
# No totals here, so usable is the legacy free*frac (keeps the 5% cushion).
reserve = LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
return (sum(f for _, f in gpus) - len(gpus) * reserve) * 1024 * 1024 - int(model_gb * _GB)
usable = sum(f * _CTX_FIT_VRAM_FRACTION for _, f in gpus)
return (usable - len(gpus) * reserve) * 1024 * 1024 - int(model_gb * _GB)
def test_tp_plan_weighted_split_on_asymmetric_big_model():
b, (ec, mac, gi, ts) = _plan(50)
reserve = b._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
assert gi == [0, 1]
# split weighted by (free - buffer), not raw free
assert ts == [48000 - reserve, 24000 - reserve]
# split weighted by (usable - buffer); with no totals usable is free*frac
assert ts == [
int(48000 * _CTX_FIT_VRAM_FRACTION - reserve),
int(24000 * _CTX_FIT_VRAM_FRACTION - reserve),
]
assert ec < 131072 # capped below native
@ -442,11 +450,9 @@ def test_tp_plan_drops_gpu_below_buffer_reserve():
# ── route auto-fallback survives a *raised* tensor-load crash ─────────
# A tensor-incompatible model makes load_model RAISE (Gemma 3n aborts) rather
# than return False. The /load fallback helper must catch that and retry with
# layer split -- stripping any --split-mode from the extras so the retry can't
# relaunch tensor -- while a non-tensor load propagates its exception. These
# exercise the real helper with a fake loader (no GPU, no llama-server).
# A tensor-incompatible model makes load_model RAISE (not return False); the
# /load fallback must catch it and retry with layer split (stripping --split-mode
# so the retry can't relaunch tensor), while a non-tensor load propagates.
class _RecordingLoader:
@ -555,15 +561,46 @@ def test_tensor_fallback_skips_layer_retry_when_cancelled():
)
def test_tensor_fallback_strips_split_mode_from_extras_on_retry(extras):
# Tensor engaged via extras (boolean False); the retry must drop every
# --split-mode form (long/short, space/=) but keep the user's other flags,
# else resolve_tensor_parallel re-enables tensor and relaunches the crash.
# --split-mode form (long/short, space/=) and force layer, keeping the user's
# other flags, else tensor is re-enabled and relaunches the crash.
loader = _RecordingLoader()
ok = asyncio.run(
load_with_tensor_fallback(loader, requested_tensor = False, extra_args = extras, label = "m")
)
assert ok is True
assert len(loader.calls) == 2
assert loader.calls[1][1] == ["-c", "4096"] # split-mode stripped, -c kept
# User --split-mode replaced by an explicit layer override; -c kept.
assert loader.calls[1][1] == ["-c", "4096", "--split-mode", "layer"]
def test_tensor_fallback_env_tensor_retry_forces_layer(monkeypatch):
# Env-only tensor (toggle off, no --split-mode extra): load_model engages
# tensor via LLAMA_ARG_SPLIT_MODE and a tensor-incompatible model crashes. The
# wrapper must (1) recognise the env tensor request and retry, and (2) force
# --split-mode layer so the retry doesn't re-engage tensor via the still-set
# env and crash again (#6312).
monkeypatch.setenv("LLAMA_ARG_SPLIT_MODE", "tensor")
calls: list = []
async def _crash_when_effectively_tensor(tensor_parallel, extra_args):
calls.append(list(extra_args) if extra_args else extra_args)
# Mirror real load_model: env-aware tensor engagement crashes.
if _effective_tensor_parallel(extra_args, tensor_parallel):
raise RuntimeError("llama-server failed to start (tensor)")
return True
ok = asyncio.run(
load_with_tensor_fallback(
_crash_when_effectively_tensor,
requested_tensor = False,
extra_args = None,
label = "m",
)
)
assert ok is True
assert len(calls) == 2
# The forced layer override neutralises the inherited tensor env on retry.
assert calls[1] == ["--split-mode", "layer"]
def test_tensor_fallback_propagates_non_tensor_crash():
@ -576,3 +613,143 @@ def test_tensor_fallback_propagates_non_tensor_crash():
_always_raise, requested_tensor = False, extra_args = None, label = "m"
)
)
# ── _plan_tensor_parallel: total-based headroom + ubatch (review fixes) ──
def test_tensor_caps_context_to_total_vram_budget():
# Partly-used 80 GB cards: 20 GB free each. With total_by_idx the planner must
# cap occupancy at 0.95*total (not spend the cushion the layer-split paths keep).
b = _kv_seeded_backend()
gpus = [(0, 20000), (1, 20000)]
totals = {0: 81920, 1: 81920}
model = int(18 * _GB)
with_total, *_ = b._plan_tensor_parallel(gpus, model, 131072, total_by_idx = totals)
without, *_ = b._plan_tensor_parallel(gpus, model, 131072)
assert with_total < without # total cap tightens the chosen context
MIB = 1024 * 1024
reserve = LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB # flat (no vocab dims)
pool_usable = sum(f - (1.0 - _CTX_FIT_VRAM_FRACTION) * totals[i] for i, f in gpus)
foot_total = (model + b._estimate_kv_cache_bytes(with_total, None)) / MIB + len(gpus) * reserve
foot_free = (model + b._estimate_kv_cache_bytes(without, None)) / MIB + len(gpus) * reserve
assert foot_total <= pool_usable + 2 # fix: fits the total-based budget
assert foot_free > pool_usable # old behavior over-spent the cushion
def test_tensor_unknown_total_keeps_fraction_cushion():
# A two-column nvidia-smi probe yields total 0. The planner must fall back to
# free*frac (keep the 5% cushion), like _select_gpus/_gpu_usable, not raw free,
# or it over-advertises context exactly where the PR is hardening the budget.
b = _kv_seeded_backend()
gpus = [(0, 20000), (1, 20000)]
MIB = 1024 * 1024
reserve = LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
model = int(18 * _GB)
ec_zero, *_ = b._plan_tensor_parallel(gpus, model, 131072, total_by_idx = {0: 0, 1: 0})
ec_none, *_ = b._plan_tensor_parallel(gpus, model, 131072)
assert ec_zero == ec_none # total 0 == total absent: both use free*frac
pool_free = sum(f for _, f in gpus)
foot = (model + b._estimate_kv_cache_bytes(ec_zero, None)) / MIB + len(gpus) * reserve
assert foot <= pool_free * _CTX_FIT_VRAM_FRACTION + 2 # within free*frac, not raw free
def test_tensor_reserve_scales_with_ubatch():
# A user --ubatch override must enlarge the per-device reserve -> less ctx room.
b = _kv_seeded_backend()
b._vocab_size = 152064 # enable the deterministic compute-buffer estimate
gpus = [(0, 16000), (1, 16000)]
model = int(18 * _GB)
small_ub, *_ = b._plan_tensor_parallel(gpus, model, 131072, n_ubatch = 512)
big_ub, *_ = b._plan_tensor_parallel(gpus, model, 131072, n_ubatch = 4096)
assert big_ub < small_ub
def test_plan_tensor_carries_unsized_mtp_flat_reserve():
# review run3 #1/#5: with a weights-only (KV-unsized) MTP reserve, the planner
# gets a non-None mtp_overhead_fn but must still subtract the flat unsized-KV
# cushion, or its binary search spends it on context. Passing the reserve must
# pick a strictly smaller context than passing 0.
b = _kv_seeded_backend()
gpus = [(0, 14000), (1, 14000)] # tight pool so the context is actually capped
model = int(8 * _GB)
weights_only = lambda c: 3 * _GB # noqa: E731 -- constant drafter weights, no KV term
ctx_no_flat, *_ = b._plan_tensor_parallel(
gpus,
model,
131072,
mtp_engaged = True,
mtp_overhead_fn = weights_only,
mtp_flat_reserve_bytes = 0,
)
ctx_flat, *_ = b._plan_tensor_parallel(
gpus,
model,
131072,
mtp_engaged = True,
mtp_overhead_fn = weights_only,
mtp_flat_reserve_bytes = 2 * _GB,
)
assert 0 < ctx_flat < ctx_no_flat
def test_tensor_admission_drops_gpu_below_usable_budget():
# A partly-used big card can clear the buffer reserve on raw free yet have no
# usable budget left (free - 0.05*total). Admit by usable budget: GPU 0 here is
# 6000 free on an 80 GB card -> usable 1904 < flat reserve 5120, so it's dropped
# (leaving <2 -> no split). Without total_by_idx, raw free 6000 >= 5120 admits it.
b = _kv_seeded_backend()
gpus = [(0, 6000), (1, 40000)]
totals = {0: 81920, 1: 81920}
_ec, _mac, gi, ts = b._plan_tensor_parallel(gpus, int(8 * _GB), 8192, total_by_idx = totals)
assert gi == [1] and ts is None # GPU 0 excluded on usable budget
_ec2, _mac2, gi_raw, _ts2 = b._plan_tensor_parallel(gpus, int(8 * _GB), 8192)
assert gi_raw == [0, 1] # raw free would have admitted both
def test_load_model_tensor_admission_and_capacity_gate_use_usable_budget():
# load_model is too entangled (subprocess + GPU probe) to drive end-to-end, so
# assert at the source level that the tensor prefilter admits on the usable
# budget (_gpu_usable), not raw free, and downgrades to layer split when the
# pooled budget can't hold weights + per-device compute buffers.
src = inspect.getsource(LlamaCppBackend.load_model)
assert "_gpu_usable(g) >= reserve_mib" in src # admit by usable budget
assert "g[1] >= reserve_mib" not in src # not raw free
assert "_tp_weight_budget_mib" in src # pooled-weight capacity gate
assert "falling back to layer split" in src # downgrade on overcommit
# The gate's required footprint must include the non-shrinkable MTP reserve,
# not weights alone, or a separate-drafter MTP load can still overcommit.
assert "_tp_mtp_floor" in src
assert "model_size + _tp_mtp_floor" in src
def test_load_model_tensor_floor_keeps_flat_reserve_for_weights_only():
# Tensor mode has no --fit valve, so a weights-only drafter (KV unsized) must
# keep the flat reserve as the draft-KV cushion, not just the byte weights
# (Finding H1, the tensor analog of the layer-split _mtp_kv_unsized handling).
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
# byte-only floor used only when KV is sizable (not the weights-only case)
assert "mtp_overhead_fnisnotNoneandnot_mtp_kv_unsized" in compact
# weights-only / dims-unavailable: flat reserve, never below the byte floor
assert "_tp_mtp_floor=max(" in compact
def test_load_model_reserves_pipeline_per_device_overhead():
# Layer split must reserve the fixed per-device overhead per EXTRA device so a
# tight multi-GPU split can't pin a context that OOMs a device (Finding A); k=1
# adds nothing.
assert LlamaCppBackend._PIPELINE_PER_DEVICE_OVERHEAD_MIB > 0
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
assert "def_subset_model_size(n_gpus:int)->int:" in compact
assert "max(0,n_gpus-1)*_pipeline_overhead_bytes" in compact
assert "_subset_model_size(n_gpus)" in compact # used in the layer-split fit
def test_load_model_restores_quantized_kv_on_tensor_downgrade():
# A quantized KV dropped for the tensor attempt must be restored if tensor
# downgrades to layer split (Finding D); captured once, restored at both the
# GPU-count and capacity-gate downgrades.
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
assert "_tensor_dropped_cache_type_kv=cache_type_kv" in compact # captured pre-null
assert compact.count("cache_type_kv=_tensor_dropped_cache_type_kv") >= 2 # restored

View file

@ -0,0 +1,71 @@
# 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 _select_torchao_spec in install_python_stack.py.
torchao's C++ extensions are built against one exact torch release, so the
installer must pick the torchao version matching the torch installed in the
venv (otherwise the cpp kernels are skipped). This pins that mapping.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# install_python_stack.py lives at repo_root/studio/install_python_stack.py
_INSTALL_SCRIPT = Path(__file__).resolve().parents[2] / "install_python_stack.py"
def _load_module(monkeypatch):
"""(Re-)import install_python_stack and return it (mirrors test_pytorch_mirror)."""
sys.modules.pop("install_python_stack", None)
monkeypatch.syspath_prepend(str(_INSTALL_SCRIPT.parent))
import install_python_stack
return install_python_stack
@pytest.mark.parametrize(
"torch_version, expected",
[
# torch 2.10 (the reported bug: cu130 resolves 2.10.0) -> 0.16.0,
# independent of the local +cuXXX/+rocm/+cpu suffix or patch level.
("2.10.0+cu130", "torchao==0.16.0"),
("2.10.0+rocm6.4", "torchao==0.16.0"),
("2.10.0+cpu", "torchao==0.16.0"),
("2.10.1", "torchao==0.16.0"),
("2.10.0", "torchao==0.16.0"),
# Pre-release / dev / rc builds: the minor is cleaned of non-digits.
("2.10.0rc1", "torchao==0.16.0"),
("2.10.0.dev20250804+cu130", "torchao==0.16.0"),
("2.10rc1", "torchao==0.16.0"),
# torch 2.11 (reachable via ROCm rocm7.2) and forward -> 0.17.0.
("2.11.0+cu130", "torchao==0.17.0"),
("2.11.0", "torchao==0.17.0"),
("2.12.0", "torchao==0.17.0"),
# torch <=2.9 keeps today's pin (already a correct match for 2.9.0).
("2.9.0+cu128", "torchao==0.14.0"),
("2.9.1", "torchao==0.14.0"),
("2.8.0", "torchao==0.14.0"),
("2.4.0", "torchao==0.14.0"),
# Unparseable / missing / non-2.x major -> conservative default.
(None, "torchao==0.14.0"),
("", "torchao==0.14.0"),
("garbage", "torchao==0.14.0"),
("2", "torchao==0.14.0"),
("3.0.0", "torchao==0.14.0"),
],
)
def test_select_torchao_spec(monkeypatch, torch_version, expected):
mod = _load_module(monkeypatch)
assert mod._select_torchao_spec(torch_version) == expected
def test_default_spec_matches_table(monkeypatch):
"""The default/floor stays the historical pin so older torch is unchanged."""
mod = _load_module(monkeypatch)
assert mod._TORCHAO_DEFAULT_SPEC == "torchao==0.14.0"
assert mod._select_torchao_spec("2.9.0") == mod._TORCHAO_DEFAULT_SPEC

View file

@ -0,0 +1,209 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Parent-side training Xet->HTTP fallback: a model-load stall respawns the
worker once with Xet disabled, preserving the DB run row. Driven via
_handle_event with a fake spawn context; no GPU, no network, no real subprocess.
"""
from __future__ import annotations
import contextlib
import logging
import queue
import sys
import threading
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 the heavy module-level imports of core/training/training.py so it imports
# under CPU-only/no-network, then restore them (see the restore loop below).
_SAVED: dict = {}
def _stub(name, mod):
_SAVED[name] = sys.modules.get(name)
sys.modules[name] = mod
_lg = _types.ModuleType("loggers")
_lg.get_logger = lambda name: logging.getLogger(name)
_stub("loggers", _lg)
_stub("structlog", _types.ModuleType("structlog"))
_mpl = _types.ModuleType("matplotlib")
_plt = _types.ModuleType("matplotlib.pyplot")
_plt.Figure = type("Figure", (), {}) # referenced in a class-def annotation
_mpl.pyplot = _plt
_stub("matplotlib", _mpl)
_stub("matplotlib.pyplot", _plt)
_hw = _types.ModuleType("utils.hardware")
_hw.prepare_gpu_selection = lambda *a, **k: (None, None)
_stub("utils.hardware", _hw)
_npl = _types.ModuleType("utils.native_path_leases")
_npl.native_path_secret_removed_for_child_start = lambda: contextlib.nullcontext()
_npl.run_without_native_path_secret = lambda fn: fn
_stub("utils.native_path_leases", _npl)
_pth = _types.ModuleType("utils.paths")
_pth.outputs_root = lambda *a, **k: "/tmp/outputs"
_stub("utils.paths", _pth)
import core.training.training as training_mod
from core.training.training import TrainingBackend
# Restore every stubbed module so this file never pollutes the shared session: a
# leaked bare ``structlog`` (no ``get_logger``) would break every later module
# that logs at import. training_mod already bound the stubs it needs at runtime.
for _name in (
"loggers",
"structlog",
"matplotlib",
"matplotlib.pyplot",
"utils.hardware",
"utils.native_path_leases",
"utils.paths",
):
_prev = _SAVED.get(_name)
if _prev is None:
sys.modules.pop(_name, None)
else:
sys.modules[_name] = _prev
@pytest.fixture(autouse = True)
def _stub_worker_module():
"""Stub ``core.training.worker`` so the respawn's lazy import of the
torch-heavy worker is never required."""
prev = sys.modules.get("core.training.worker")
stub = _types.ModuleType("core.training.worker")
stub.run_training_process = lambda **kwargs: None
sys.modules["core.training.worker"] = stub
yield
if prev is None:
sys.modules.pop("core.training.worker", None)
else:
sys.modules["core.training.worker"] = prev
class _FakeProc:
def __init__(self, **kwargs):
self._alive = True
self.pid = 4321
self.kwargs = kwargs
def start(self):
pass
def is_alive(self):
return self._alive
def terminate(self):
self._alive = False
def kill(self):
self._alive = False
def join(self, timeout = None):
self._alive = False
class _FakeQueue:
def put(self, *a, **k):
pass
def get(self, *a, **k):
raise queue.Empty
class _FakeCtx:
def __init__(self):
self.spawned: list = []
def Queue(self):
return _FakeQueue()
def Process(self, **kwargs):
self.spawned.append(kwargs)
return _FakeProc(**kwargs)
def _backend_mid_load():
b = TrainingBackend()
b._last_full_config = {"model_name": "org/model", "disable_xet": False, "hf_token": "tok"}
b._in_model_load = True
b._xet_fallback_used = False
proc = _FakeProc()
b._proc = proc
return b, proc
def test_stall_during_load_arms_respawn_and_terminates_worker():
b, proc = _backend_mid_load()
b._handle_event({"type": "stall", "message": "no progress for 180s"})
assert b._needs_xet_respawn is True
assert b._xet_fallback_used is True
assert proc.is_alive() is False, "stalled worker must be terminated"
def test_respawn_uses_disable_xet_and_preserves_run_row(monkeypatch):
b, _ = _backend_mid_load()
b._handle_event({"type": "stall", "message": "x"})
fake_ctx = _FakeCtx()
monkeypatch.setattr(training_mod, "_CTX", fake_ctx)
monkeypatch.setattr(b, "_pump_loop", lambda: None) # neutralize the new pump
created = {"n": 0}
finalized = {"n": 0}
monkeypatch.setattr(
b, "_ensure_db_run_created", lambda: created.__setitem__("n", created["n"] + 1)
)
monkeypatch.setattr(
b, "_finalize_run_in_db", lambda **k: finalized.__setitem__("n", finalized["n"] + 1)
)
b._respawn_worker_disable_xet()
assert len(fake_ctx.spawned) == 1, "respawn must start exactly one worker"
cfg = fake_ctx.spawned[0]["kwargs"]["config"]
assert cfg["disable_xet"] is True, "respawned worker must run with Xet disabled"
assert cfg["model_name"] == "org/model"
assert created["n"] == 0, "respawn must not recreate the DB run row"
assert finalized["n"] == 0, "a successful respawn must not finalize the run as error"
def test_second_stall_surfaces_error_without_respawn():
b, proc = _backend_mid_load()
b._xet_fallback_used = True # HTTP fallback already spent
b._handle_event({"type": "stall", "message": "stalled again over http"})
assert b._needs_xet_respawn is False
assert b._progress.error and "stalled" in b._progress.error.lower()
assert proc.is_alive() is False
def test_model_load_completed_disarms_recovery():
b, _ = _backend_mid_load()
b._handle_event({"type": "model_load_completed"})
assert b._in_model_load is False
# A stall after the load finished is not a transport stall to recover from.
b._handle_event({"type": "stall", "message": "post-load"})
assert b._needs_xet_respawn is False
def test_model_load_started_arms_recovery_window():
b = TrainingBackend()
assert b._in_model_load is False
b._handle_event({"type": "model_load_started"})
assert b._in_model_load is True
def test_child_should_disable_xet_truth_table():
from utils.hf_xet_fallback import child_should_disable_xet
assert child_should_disable_xet({"disable_xet": True}) is True
assert child_should_disable_xet({"disable_xet": False}) is False
assert child_should_disable_xet({}) is False

View file

@ -88,6 +88,19 @@ class TestGetDevice:
):
assert _reset_and_detect() == DeviceType.CUDA
@needs_torch
def test_detect_survives_device0_probe_failure(self, capsys):
# is_available() True but the device-0 name probe raises: startup must
# still resolve CUDA rather than crash.
with (
patch("utils.hardware.hardware._has_torch", return_value = True),
patch("torch.cuda.is_available", return_value = True),
patch("torch.cuda.device_count", return_value = 1),
patch("torch.cuda.get_device_properties", side_effect = RuntimeError("probe")),
):
assert _reset_and_detect() == DeviceType.CUDA
assert "<unavailable>" in capsys.readouterr().out
@needs_mlx
def test_returns_mlx_when_on_apple_silicon_with_mlx(self):
with (
@ -303,6 +316,103 @@ class TestLogGpuMemory:
assert "No GPU available" in captured.out
# ========== CUDA_DEVICE_ORDER pinning ==========
class TestCudaDeviceOrder:
"""Importing the hardware module pins CUDA_DEVICE_ORDER=PCI_BUS_ID when unset,
but setdefault keeps an explicit user override, so nvidia-smi indices, torch
ordinals, and CUDA_VISIBLE_DEVICES agree on a mixed-GPU host."""
@staticmethod
def _order_after_fresh_import(preset):
# Fresh interpreter so the module-level setdefault runs against a clean env.
import os, subprocess, sys
from pathlib import Path
env = os.environ.copy()
backend = str(Path(__file__).resolve().parents[1])
existing = env.get("PYTHONPATH", "")
# Avoid a trailing os.pathsep (empty entry -> cwd on sys.path) when unset.
env["PYTHONPATH"] = (backend + os.pathsep + existing) if existing else backend
if preset is None:
env.pop("CUDA_DEVICE_ORDER", None)
else:
env["CUDA_DEVICE_ORDER"] = preset
out = subprocess.run(
[
sys.executable,
"-c",
"import os, utils.hardware.hardware; print(os.environ.get('CUDA_DEVICE_ORDER'))",
],
env = env,
capture_output = True,
text = True,
check = True,
)
return out.stdout.strip().splitlines()[-1]
def test_import_pins_pci_bus_id_when_unset(self):
assert self._order_after_fresh_import(None) == "PCI_BUS_ID"
def test_import_respects_explicit_user_override(self):
assert self._order_after_fresh_import("FASTEST_FIRST") == "FASTEST_FIRST"
# ========== _print_cuda_device_list() ==========
class TestPrintCudaDeviceList:
"""The startup console lists every CUDA GPU with its index, not just
device 0, so a multi-GPU host shows the full available set."""
@needs_torch
def test_lists_all_devices_when_multi_gpu(self, capsys):
props = [
MagicMock(name = "p0"),
MagicMock(name = "p1"),
]
props[0].name = "NVIDIA GeForce RTX 5090"
props[1].name = "NVIDIA RTX PRO 6000 Blackwell Workstation Edition"
with (
patch("torch.cuda.device_count", return_value = 2),
patch("torch.cuda.get_device_properties", side_effect = lambda i: props[i]),
):
_hw_module._print_cuda_device_list(is_rocm = False)
out = capsys.readouterr().out
assert "[0] NVIDIA GeForce RTX 5090" in out
assert "[1] NVIDIA RTX PRO 6000 Blackwell Workstation Edition" in out
assert "CUDA_DEVICE_ORDER=" in out
@needs_torch
def test_silent_on_single_gpu(self, capsys):
with patch("torch.cuda.device_count", return_value = 1):
_hw_module._print_cuda_device_list(is_rocm = False)
assert capsys.readouterr().out == ""
@needs_torch
def test_never_raises_on_probe_failure(self, capsys):
with patch("torch.cuda.device_count", side_effect = RuntimeError("no cuda")):
_hw_module._print_cuda_device_list(is_rocm = False)
assert capsys.readouterr().out == ""
@needs_torch
def test_rocm_label_omits_cuda_device_order(self, capsys):
# CUDA_DEVICE_ORDER governs CUDA only, so the ROCm listing must not claim it.
props = [MagicMock(), MagicMock()]
props[0].name = "AMD Instinct MI300X"
props[1].name = "AMD Instinct MI300X"
with (
patch("torch.cuda.device_count", return_value = 2),
patch("torch.cuda.get_device_properties", side_effect = lambda i: props[i]),
):
_hw_module._print_cuda_device_list(is_rocm = True)
out = capsys.readouterr().out
assert "ROCm devices (2):" in out
assert "CUDA_DEVICE_ORDER" not in out
assert "[0] AMD Instinct MI300X" in out
# ========== format_error_message() ==========

View file

@ -0,0 +1,86 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""routes/inference.py::validate_model surfaces actionable RuntimeError/ValueError
messages (e.g. "llama-server binary not found - run setup.sh") instead of a blank
"Invalid model", while keeping unexpected exceptions generic so internals never
leak to the client.
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import pytest
_BACKEND = Path(__file__).resolve().parents[1]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
pytest.importorskip("fastapi")
from fastapi import HTTPException # noqa: E402
import routes.inference as inf # noqa: E402
from models.inference import ValidateModelRequest # noqa: E402
def _provoke(
monkeypatch,
exc: BaseException,
*,
native: bool = False,
) -> HTTPException:
"""Drive validate_model so from_identifier raises ``exc``; return the
HTTPException it converts that into."""
monkeypatch.setattr(
inf,
"_resolve_model_identifier_for_request",
lambda request, operation: ("org/repo", "org/repo", native),
)
def _raise(*_args, **_kwargs):
raise exc
monkeypatch.setattr(inf.ModelConfig, "from_identifier", staticmethod(_raise))
req = ValidateModelRequest(model_path = "org/repo")
with pytest.raises(HTTPException) as excinfo:
asyncio.run(inf.validate_model(req, current_subject = "tester"))
return excinfo.value
def test_runtime_error_surfaces_actionable_message(monkeypatch):
err = RuntimeError(
"llama-server binary not found - cannot load GGUF models. "
"Run setup.sh to build it, or set LLAMA_SERVER_PATH."
)
http = _provoke(monkeypatch, err)
assert http.status_code == 400
assert "llama-server binary not found" in http.detail
assert http.detail != "Invalid model"
def test_value_error_not_supported_is_wrapped(monkeypatch):
http = _provoke(monkeypatch, ValueError("architecture FooBar is not supported"))
assert http.status_code == 400
assert "not supported yet" in http.detail.lower()
# Original cause is preserved for context.
assert "FooBar" in http.detail
def test_unexpected_exception_stays_generic(monkeypatch):
# A non-user-facing exception type must NOT have its message surfaced.
http = _provoke(monkeypatch, KeyError("secret-internal-detail"))
assert http.status_code == 400
assert http.detail == "Invalid model"
assert "secret-internal-detail" not in http.detail
def test_empty_runtime_error_falls_back_to_generic(monkeypatch):
# A RuntimeError with no message should not produce an empty 400 detail.
http = _provoke(monkeypatch, RuntimeError(""))
assert http.status_code == 400
assert http.detail == "Invalid model"

View file

@ -211,6 +211,22 @@ class TestWindowsGpuDetectionAfter5106Fix:
gpus = LlamaCppBackend._get_gpu_free_memory()
assert gpus == [(1, 24576)], gpus
def test_get_gpu_memory_parses_three_and_two_column(self, monkeypatch):
"""Total is parsed when present; a legacy two-column line or a non-integer
total ("N/A") yields total 0 (back-compat) rather than dropping the GPU,
which would silently spill to CPU."""
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising = False)
with _mock_nvidia_smi_run("0, 22805, 24576\n"):
assert LlamaCppBackend._get_gpu_memory() == [(0, 22805, 24576)]
with _mock_nvidia_smi_run("0, 22805\n"):
assert LlamaCppBackend._get_gpu_memory() == [(0, 22805, 0)]
# A non-integer total must keep the GPU (total 0), not drop it.
with _mock_nvidia_smi_run("0, 22805, N/A\n"):
assert LlamaCppBackend._get_gpu_memory() == [(0, 22805, 0)]
# A bad free still skips that line (free is required).
with _mock_nvidia_smi_run("0, N/A, 24576\n1, 22805, 24576\n"):
assert LlamaCppBackend._get_gpu_memory() == [(1, 22805, 24576)]
def test_windows_install_dir_has_all_three_cudart_dlls(self, tmp_path):
"""All three bundle DLLs must land in install_dir/build/bin/
Release; any missing one breaks ggml-cuda.dll's PE import chain."""

View file

@ -35,6 +35,21 @@ from typing import Optional, Dict, Any
logger = get_logger(__name__)
# ── GPU index ordering ──────────────────────────────────────────────────────
# CUDA defaults to CUDA_DEVICE_ORDER=FASTEST_FIRST, numbering GPUs by compute
# performance. nvidia-smi -- and every free-VRAM probe in Studio -- numbers GPUs
# by PCI bus id instead. On a mixed-GPU host (e.g. an RTX 5090 alongside an RTX
# PRO 6000) the two orderings disagree, so an index picked from nvidia-smi data
# ("the emptiest card is GPU 1") gets written into CUDA_VISIBLE_DEVICES and then
# reinterpreted by CUDA against FASTEST_FIRST -- landing the model on a different
# physical GPU than the one selected. Pinning PCI_BUS_ID makes torch, nvidia-smi,
# and CUDA_VISIBLE_DEVICES share a single index space, matching what users see in
# `nvidia-smi -L`. Set at import (before any torch.cuda call latches the order
# at context creation) and inherited by child processes, since the llama-server
# and spawn workers copy os.environ. setdefault so an explicit user override wins.
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
# ========== Device Enum ==========
@ -91,6 +106,40 @@ def _has_mlx() -> bool:
return False
def _print_cuda_device_list(is_rocm: bool) -> None:
"""List every visible CUDA/ROCm GPU with its index at startup.
The "Hardware detected" banner names only device 0, which hides the other
cards on a multi-GPU host. This lists the full visible set in CUDA-ordinal
order, matching `nvidia-smi -L` when no CUDA_VISIBLE_DEVICES mask is set
(under a mask the indices are visible ordinals, not physical PCI ids).
CUDA_DEVICE_ORDER governs only CUDA, so it is shown for CUDA but not ROCm.
No-ops on single-GPU hosts and never raises -- it is purely informational.
"""
try:
import torch
count = torch.cuda.device_count()
if count <= 1:
return
if is_rocm:
header = f"ROCm devices ({count}):"
else:
order = os.environ.get("CUDA_DEVICE_ORDER", "default")
header = f"CUDA devices ({count}, CUDA_DEVICE_ORDER={order}):"
lines = [header]
for i in range(count):
try:
name = torch.cuda.get_device_properties(i).name
except Exception as e:
logger.debug("CUDA device %d property probe failed: %s", i, e)
name = "<unavailable>"
lines.append(f" [{i}] {name}")
print("\n".join(lines))
except Exception:
return # purely informational; never disrupt startup
def detect_hardware() -> DeviceType:
"""
Detect the best compute device and set the module-level DEVICE global.
@ -112,7 +161,11 @@ def detect_hardware() -> DeviceType:
if torch.cuda.is_available():
DEVICE = DeviceType.CUDA
CHAT_ONLY = False
device_name = torch.cuda.get_device_properties(0).name
try:
device_name = torch.cuda.get_device_properties(0).name
except Exception as e:
logger.debug("CUDA device 0 property probe failed: %s", e)
device_name = "<unavailable>"
# Distinguish ROCm from CUDA for display only (DeviceType stays CUDA).
# AMD SDK wheels don't set torch.version.hip, so fall back to __version__.
@ -123,6 +176,7 @@ def detect_hardware() -> DeviceType:
print(f"Hardware detected: ROCm (HIP {_hip_label}) -- {device_name}")
else:
print(f"Hardware detected: CUDA -- {device_name}")
_print_cuda_device_list(IS_ROCM)
return DEVICE
# --- XPU: Intel GPU ---

View file

@ -0,0 +1,405 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Xet-primary HF downloads with an automatic HTTP fallback on a no-progress stall.
Xet (``hf_xet``) is the fast default but can hang with no progress and no
exception, and a blocked native thread cannot be killed. Keep Xet primary; fall
back to plain HTTP only when the parent observes a stall. ``HF_HUB_DISABLE_XET``
is read at import time, so the fallback runs in a fresh ``spawn`` child (not a
thread) that sets the env before importing ``huggingface_hub``. Cached files
short-circuit with no child; deterministic errors (401/403/404/disk-full) and
cancellation propagate without a fallback. Mirrors the safetensors inference
recovery in core/inference/{orchestrator,worker}.py.
"""
from __future__ import annotations
import multiprocessing as mp
import os
import queue
import signal
import sys
import threading
import time
from typing import Any, Callable, Optional
from loggers import get_logger
logger = get_logger(__name__)
_CTX = mp.get_context("spawn")
# Defaults match the existing inference watchdog and hub shutdown deadline.
DEFAULT_HEARTBEAT_INTERVAL = 30.0
DEFAULT_STALL_TIMEOUT = 180.0
DEFAULT_GRACE_PERIOD = 10.0
_POLL_INTERVAL = 0.5
class DownloadStallError(RuntimeError):
"""Raised when no download progress is observed for too long.
Canonical home; orchestrator.py re-imports it so all paths share one type.
"""
def child_should_disable_xet(config: dict) -> bool:
"""Single source of truth for the per-worker Xet env flip."""
return bool(config.get("disable_xet"))
def get_hf_download_state(
repo_ids: Optional[list[str]] = None, *, repo_type: str = "model"
) -> Optional[tuple[int, bool]]:
"""Return ``(total_on_disk_bytes, has_incomplete)`` for the active HF cache.
Sparse-aware (st_blocks based) so a sparse Xet/``hf_transfer`` ``.incomplete``
is not mistaken for full-size progress. ``None`` means the state could not be
measured, so callers skip stall logic for that tick.
"""
try:
from hub.utils.hf_cache_state import (
blob_bytes_present,
has_active_incomplete_blobs,
hf_cache_root,
iter_active_repo_cache_dirs,
)
if hf_cache_root() is None:
return (0, False)
total = 0
has_incomplete = False
for repo_id in repo_ids or []:
# Skip local paths: HF IDs never start with / . ~ or contain "\".
if not repo_id or repo_id.startswith(("/", ".", "~")) or "\\" in repo_id:
continue
for entry in iter_active_repo_cache_dirs(repo_type, repo_id):
blobs_dir = entry / "blobs"
if not blobs_dir.is_dir():
continue
for blob in blobs_dir.iterdir():
try:
if blob.is_file():
total += blob_bytes_present(blob)
except OSError:
pass
if has_active_incomplete_blobs(repo_type, repo_id):
has_incomplete = True
return (total, has_incomplete)
except Exception as e:
logger.debug("Failed to determine HF download state: %s", e)
return None
def start_watchdog(
*,
repo_ids: list[str],
on_stall: Callable[[str], None],
repo_type: str = "model",
interval: float = DEFAULT_HEARTBEAT_INTERVAL,
stall_timeout: float = DEFAULT_STALL_TIMEOUT,
xet_disabled: bool = False,
on_heartbeat: Optional[Callable[[str], None]] = None,
) -> threading.Event:
"""Start a daemon thread that fires ``on_stall(message)`` exactly once iff a
``*.incomplete`` is present AND the on-disk size is unchanged for
*stall_timeout* seconds. The timer resets while no ``*.incomplete`` exists, so
post-download init is never misread as a stall. Returns a stop event the
caller sets when the download phase ends.
"""
stop = threading.Event()
transport = "https" if xet_disabled else "xet"
fired = False
def _beat() -> None:
nonlocal fired
state = get_hf_download_state(repo_ids, repo_type = repo_type)
last_size = state[0] if state is not None else 0
last_change = time.monotonic()
while not stop.wait(interval):
state = get_hf_download_state(repo_ids, repo_type = repo_type)
now = time.monotonic()
if state is None:
if on_heartbeat is not None:
on_heartbeat(f"Downloading ({transport} transport)...")
continue
current_size, has_incomplete = state
if current_size != last_size:
last_size = current_size
last_change = now
# Reset unless .incomplete confirms an active download, so model init
# and lock waits are not counted as a stall.
if not has_incomplete:
last_change = now
elif now - last_change >= stall_timeout:
if not fired:
fired = True
on_stall(
f"Download appears stalled ({transport} transport) "
f"-- no progress for {int(now - last_change)}s"
)
return
if on_heartbeat is not None:
on_heartbeat(f"Downloading ({transport} transport)...")
threading.Thread(target = _beat, daemon = True, name = "hf-xet-watchdog").start()
return stop
def _download_child_entry(
*,
repo_id: str,
filename: str,
token: Optional[str],
repo_type: str,
disable_xet: bool,
result_queue: Any,
) -> None:
"""Spawn-child entrypoint: download one file and report the result.
Top-level and picklable. Sets the Xet env BEFORE importing huggingface_hub,
forms its own process group so the parent can kill the whole transfer, and
never logs the token or signed URLs.
"""
if hasattr(os, "setsid"):
try:
os.setsid()
except OSError:
pass
if disable_xet:
os.environ["HF_HUB_DISABLE_XET"] = "1"
# Keep the HTTP writer sequential and resumable (hf_transfer leaves sparse
# partials a sequential resume cannot safely continue).
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
# Test-only fault injection (never set in production): stall the Xet attempt
# so the watchdog + HTTP fallback can be exercised against a real repo.
if not disable_xet and os.environ.get("UNSLOTH_HF_XET_FORCE_STALL") == "1":
import time as _t
try:
from huggingface_hub.constants import HF_HUB_CACHE
blobs = os.path.join(HF_HUB_CACHE, "models--" + repo_id.replace("/", "--"), "blobs")
os.makedirs(blobs, exist_ok = True)
with open(os.path.join(blobs, "xet-force-stall.incomplete"), "wb") as fh:
fh.write(b"\0" * 4096)
except OSError:
pass
while True:
_t.sleep(3600)
try:
from huggingface_hub import hf_hub_download
path = hf_hub_download(
repo_id = repo_id,
filename = filename,
repo_type = repo_type,
token = token,
)
result_queue.put({"ok": True, "path": path})
except BaseException as e: # noqa: BLE001 - report every failure to the parent
error = f"{type(e).__name__}: {e}"
try:
from hub.utils.download_registry import scrub_secrets
error = scrub_secrets(error, hf_token = token)
except Exception:
pass
result_queue.put({"ok": False, "error": error})
def _terminate_process_group(proc: "mp.process.BaseProcess", grace_period: float) -> None:
"""Kill *proc* and its whole process group (Xet may spawn helper procs).
The child calls ``os.setsid()`` so its pgid equals its pid; signal via
``os.killpg(pid, ...)`` -- NOT ``getpgid``, which before the child becomes a
group leader resolves to OUR group. SIGTERM, then SIGKILL after *grace_period*.
"""
pid = proc.pid
def _signal_group(sig: int) -> None:
if pid is not None and hasattr(os, "killpg"):
try:
os.killpg(pid, sig)
return
except (ProcessLookupError, PermissionError, OSError):
pass
# Windows or pre-setsid: best effort on the single process.
try:
proc.terminate() if sig != getattr(signal, "SIGKILL", -9) else proc.kill()
except Exception:
pass
_signal_group(getattr(signal, "SIGTERM", signal.SIGINT))
proc.join(timeout = grace_period)
if proc.is_alive():
_signal_group(getattr(signal, "SIGKILL", signal.SIGTERM))
proc.join(timeout = 5.0)
def _run_download_attempt(
repo_id: str,
filename: str,
token: Optional[str],
*,
repo_type: str,
disable_xet: bool,
cancel_event: Optional[threading.Event],
stall_timeout: float,
interval: float,
grace_period: float,
on_status: Optional[Callable[[str], None]],
) -> tuple[str, Optional[str]]:
"""Run one download in a spawn child supervised by the no-progress watchdog.
Returns ``("ok", path)``, ``("stall", None)``, ``("cancelled", None)``, or
``("error", message)``. This is the seam tests monkeypatch to avoid spawning.
"""
result_queue: Any = _CTX.Queue()
proc = _CTX.Process(
target = _download_child_entry,
kwargs = dict(
repo_id = repo_id,
filename = filename,
token = token,
repo_type = repo_type,
disable_xet = disable_xet,
result_queue = result_queue,
),
daemon = True,
)
proc.start()
stalled = threading.Event()
stop_watchdog = start_watchdog(
repo_ids = [repo_id],
on_stall = lambda msg: stalled.set(),
repo_type = repo_type,
interval = interval,
stall_timeout = stall_timeout,
xet_disabled = disable_xet,
on_heartbeat = on_status,
)
result: Optional[dict] = None
try:
while proc.is_alive():
if cancel_event is not None and cancel_event.is_set():
_terminate_process_group(proc, grace_period)
return ("cancelled", None)
if stalled.is_set():
_terminate_process_group(proc, grace_period)
return ("stall", None)
try:
result = result_queue.get(timeout = _POLL_INTERVAL)
break
except queue.Empty:
continue
else:
# Process exited; drain any result it enqueued.
try:
result = result_queue.get_nowait()
except queue.Empty:
result = None
finally:
stop_watchdog.set()
proc.join(timeout = grace_period)
if result is None:
return (
"error",
f"download process for '{repo_id}/{filename}' exited "
f"(code={proc.exitcode}) without a result",
)
if result.get("ok"):
return ("ok", result["path"])
return ("error", result.get("error") or "unknown download error")
def hf_hub_download_with_xet_fallback(
repo_id: str,
filename: str,
token: Optional[str],
*,
cancel_event: Optional[threading.Event] = None,
repo_type: str = "model",
stall_timeout: float = DEFAULT_STALL_TIMEOUT,
interval: float = DEFAULT_HEARTBEAT_INTERVAL,
grace_period: float = DEFAULT_GRACE_PERIOD,
on_status: Optional[Callable[[str], None]] = None,
) -> str:
"""Download a single file with Xet primary and HTTP as a stall-only fallback.
Returns the local cache path. Raises ``RuntimeError("Cancelled")`` if
*cancel_event* is set, re-raises a deterministic child error unchanged (no
fallback), and raises ``DownloadStallError`` only if BOTH transports stall.
"""
# Finalized blob already cached: return it with no child and no network.
try:
from huggingface_hub import try_to_load_from_cache
cached = try_to_load_from_cache(repo_id, filename, repo_type = repo_type)
if isinstance(cached, str) and os.path.exists(cached):
return cached
except Exception as e:
logger.debug("Cached probe failed for %s/%s: %s", repo_id, filename, e)
if cancel_event is not None and cancel_event.is_set():
raise RuntimeError("Cancelled")
disable_xet = False
for attempt in range(2):
if disable_xet:
# Purge a non-HTTP partial before resuming over HTTP: an HTTP resume
# over a sparse Xet/hf_transfer partial silently corrupts the blob.
try:
from hub.utils.download_registry import prepare_cache_for_transport
prepare_cache_for_transport(repo_type, repo_id, "http")
except Exception as e:
logger.debug("prepare_cache_for_transport failed for %s: %s", repo_id, e)
kind, payload = _run_download_attempt(
repo_id,
filename,
token,
repo_type = repo_type,
disable_xet = disable_xet,
cancel_event = cancel_event,
stall_timeout = stall_timeout,
interval = interval,
grace_period = grace_period,
on_status = on_status,
)
if kind == "ok":
return payload # type: ignore[return-value]
if kind == "cancelled":
raise RuntimeError("Cancelled")
if kind == "error":
# Deterministic failure: the other transport would fail identically.
raise RuntimeError(payload)
# kind == "stall"
if attempt == 0 and not disable_xet:
logger.warning(
"Download stalled for '%s/%s' -- retrying with HF_HUB_DISABLE_XET=1",
repo_id,
filename,
)
if on_status is not None:
on_status(f"{repo_id}/{filename}: Xet stalled, retrying over HTTP")
disable_xet = True
continue
raise DownloadStallError(
f"Download stalled for '{repo_id}/{filename}' even with "
f"HF_HUB_DISABLE_XET=1 -- check your network connection"
)
# Unreachable: the loop either returns or raises on each attempt.
raise DownloadStallError(f"Download failed for '{repo_id}/{filename}'")

View file

@ -33,6 +33,8 @@ _INSTALL_MARKER_NAME = "UNSLOTH_PREBUILT_INFO.json"
_marker_cache: dict[str, Optional[dict]] = {}
_release_memo: dict[str, tuple[float, Optional[str]]] = {}
# Newest-release asset sizes (name -> bytes), memoized like the tag (24h TTL).
_assets_memo: dict[str, tuple[float, dict[str, int]]] = {}
def _cache_dir() -> Path:
@ -180,6 +182,113 @@ def latest_published_release(repo: str, *, force_refresh: bool = False) -> Optio
return latest
def _fetch_latest_release_assets(repo: str, timeout: float = 5.0) -> Optional[dict[str, int]]:
"""Asset name -> size (bytes) for the newest published release of `repo`,
selected exactly like _fetch_latest_release_tag. None on any failure."""
import urllib.error
import urllib.request
url = f"https://api.github.com/repos/{repo}/releases?per_page=30"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "unsloth-studio-freshness-check",
}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers = headers)
try:
with urllib.request.urlopen(req, timeout = timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (
urllib.error.URLError,
urllib.error.HTTPError,
OSError,
json.JSONDecodeError,
) as exc:
logger.debug("freshness asset fetch failed", repo = repo, error = str(exc))
return None
if not isinstance(data, list):
return None
published = [
r
for r in data
if isinstance(r, dict)
and not r.get("draft")
and not r.get("prerelease")
and isinstance(r.get("tag_name"), str)
and r.get("tag_name")
]
if not published:
return None
newest = max(published, key = lambda r: r.get("published_at") or "")
assets: dict[str, int] = {}
for a in newest.get("assets") or []:
name, size = a.get("name"), a.get("size")
if isinstance(name, str) and isinstance(size, int):
assets[name] = size
return assets
def latest_release_assets(repo: str, *, force_refresh: bool = False) -> Optional[dict[str, int]]:
"""Newest-release asset sizes for `repo`, memoized (24h TTL). None when
offline and never fetched. In-memory only -- a restart simply re-fetches."""
if not repo:
return None
now = time.time()
if not force_refresh:
memo = _assets_memo.get(repo)
if memo and now - memo[0] < _RELEASE_CACHE_TTL_SECONDS:
return memo[1]
assets = _fetch_latest_release_assets(repo)
if assets is None:
memo = _assets_memo.get(repo)
return memo[1] if memo else None
_assets_memo[repo] = (now, assets)
return assets
def update_download_size_bytes(
marker: Optional[dict],
latest_tag: Optional[str],
repo: Optional[str],
*,
force_refresh: bool = False,
) -> Optional[int]:
"""Download size of the latest-release asset matching this host's installed
bundle (same platform/arch/runtime suffix as the installed asset). None when
there is no marker asset, the latest assets can't be read, or no match."""
if not marker or not latest_tag or not repo:
return None
installed_asset = marker.get("asset")
if not isinstance(installed_asset, str):
return None
# Tag-independent platform suffix: accept the fork's "app-*" bundles and the
# upstream ggml-org "ubuntu-*"/"win-*" prebuilts ("windows" before "win").
m = re.search(r"-((?:linux|ubuntu|windows|win|macos|darwin)-.*)$", installed_asset)
if not m:
return None
suffix = m.group(1)
# Upstream ubuntu/win assets live in the marker's binary_repo, not the fork
# publish repo; try the publish repo first, then it.
repos = [repo]
binary_repo = marker.get("binary_repo")
if isinstance(binary_repo, str) and binary_repo and binary_repo != repo:
repos.append(binary_repo)
want = f"app-{latest_tag}-{suffix}"
for r in repos:
assets = latest_release_assets(r, force_refresh = force_refresh)
if not assets:
continue
if want in assets:
return assets[want]
# Tag formatting can vary (mix suffixes); fall back to the platform suffix.
for name, size in assets.items():
if name.endswith(suffix):
return size
return None
def _parse_installed_at(value: object) -> Optional[datetime]:
if not isinstance(value, str) or not value:
return None
@ -313,6 +422,7 @@ def reset_caches(*, drop_disk: bool = False) -> None:
open (off) instead of pointing at the just-replaced build."""
_marker_cache.clear()
_release_memo.clear()
_assets_memo.clear()
if drop_disk:
import shutil

View file

@ -37,9 +37,11 @@ from utils.llama_cpp_freshness import (
_INSTALL_MARKER_NAME,
check_prebuilt_freshness,
latest_published_release,
latest_release_assets,
parse_base_build,
read_install_marker,
reset_caches,
update_download_size_bytes,
)
logger = structlog.get_logger(__name__)
@ -291,6 +293,18 @@ def _source_build_status(binary: str, *, force_refresh: bool) -> Optional[dict]:
update_available = False
# Display the mix tag when that's what makes it newer; otherwise the base.
latest = release_tag if latest_is_mix else base_tag
# Size of the resolved prebuilt, so source builds show it like the marker
# path. Fails open to None (offline / asset absent from the release).
update_size_bytes = None
if update_available:
asset_name = res.get("asset")
if isinstance(asset_name, str) and asset_name:
try:
assets = latest_release_assets(res.get("repo"), force_refresh = force_refresh)
if assets:
update_size_bytes = assets.get(asset_name)
except Exception as exc: # pragma: no cover - network defensive
logger.debug("llama update: source-build size lookup failed", error = str(exc))
with _job_lock:
job = dict(_job)
return {
@ -303,6 +317,7 @@ def _source_build_status(binary: str, *, force_refresh: bool) -> Optional[dict]:
"installed_at_utc": None,
"age_days": None,
"source_build": True,
"update_size_bytes": update_size_bytes,
"job": job,
}
@ -345,6 +360,20 @@ def get_update_status(*, force_refresh: bool = False) -> dict:
# (see llama_cpp_freshness.is_behind).
update_available = bool(freshness.get("has_marker") and freshness.get("behind"))
# Size of the prebuilt that Update would download, for the banner. Only when
# an update is offered; fails open to None (offline / no matching asset).
update_size_bytes = None
if update_available:
try:
update_size_bytes = update_download_size_bytes(
marker,
latest,
freshness.get("published_repo") or repo,
force_refresh = force_refresh,
)
except Exception as exc: # pragma: no cover - network defensive
logger.debug("llama update: size lookup failed", error = str(exc))
with _job_lock:
job = dict(_job)
@ -358,6 +387,7 @@ def get_update_status(*, force_refresh: bool = False) -> dict:
"installed_at_utc": freshness.get("installed_at_utc"),
"age_days": freshness.get("age_days"),
"source_build": False,
"update_size_bytes": update_size_bytes,
"job": job,
}

View file

@ -50,6 +50,10 @@ _CACHE_MAX_ENTRIES = 4096
# keyed by (file cache key, wanted key). None = key absent / file unreadable.
_BOOL_CACHE: Dict[Tuple[_CacheKey, str], Optional[bool]] = {}
# Native training context length (``{arch}.context_length``). None = absent /
# unreadable. Lets the UI show the real context ceiling before a model loads.
_CONTEXT_CACHE: Dict[_CacheKey, Optional[int]] = {}
def _cache_key(path: str) -> Optional[_CacheKey]:
try:
@ -138,6 +142,92 @@ def _parse_gguf_header(path: str) -> Optional[Dict[str, str]]:
return out
def read_gguf_context_length(path: str) -> Optional[int]:
"""Return the GGUF's native training context length (``{arch}.context_length``),
or ``None`` if missing/unreadable/not a GGUF. Cached by (path, mtime, size).
Lets the UI populate the context slider before the model is loaded."""
key = _cache_key(path)
if key is None:
return None
with _CACHE_LOCK:
if key in _CONTEXT_CACHE:
return _CONTEXT_CACHE[key]
result = _parse_gguf_context_length(path)
with _CACHE_LOCK:
while len(_CONTEXT_CACHE) >= _CACHE_MAX_ENTRIES:
try:
_CONTEXT_CACHE.pop(next(iter(_CONTEXT_CACHE)))
except StopIteration:
break
_CONTEXT_CACHE[key] = result
return result
def _parse_gguf_context_length(path: str) -> Optional[int]:
# The context key is architecture-namespaced (``llama.context_length`` etc.),
# so we learn the key only after reading ``general.architecture``. GGUF writes
# general.* before arch.* keys, matching the loader's own parser.
ctx_key: Optional[str] = None
try:
with open(path, "rb") as f:
head = f.read(24)
if len(head) < 24:
return None
magic, _version, _tcount, kv_count = struct.unpack("<IIQQ", head)
if magic != _GGUF_MAGIC:
return None
for _ in range(kv_count):
try:
klen_bytes = f.read(8)
if len(klen_bytes) < 8:
break
klen = struct.unpack("<Q", klen_bytes)[0]
if klen > 1 << 20: # 1 MB sanity bound
break
kbytes = f.read(klen)
if len(kbytes) < klen:
break
key = kbytes.decode("utf-8", "replace")
vt_bytes = f.read(4)
if len(vt_bytes) < 4:
break
vtype = struct.unpack("<I", vt_bytes)[0]
if vtype == 8 and key == "general.architecture":
slen_bytes = f.read(8)
if len(slen_bytes) < 8:
break
slen = struct.unpack("<Q", slen_bytes)[0]
if slen > 1 << 22: # 4 MB sanity bound
break
sbytes = f.read(slen)
if len(sbytes) < slen:
break
ctx_key = f"{sbytes.decode('utf-8', 'replace')}.context_length"
elif ctx_key is not None and key == ctx_key and vtype in (4, 10):
width = 4 if vtype == 4 else 8
n_bytes = f.read(width)
if len(n_bytes) < width:
break
value = struct.unpack("<I" if vtype == 4 else "<Q", n_bytes)[0]
# A real context length is positive; treat 0/garbage as
# absent so the UI never builds a slider with max < min.
return value if value > 0 else None
else:
if not _skip_gguf_value(f, vtype):
break
except (struct.error, UnicodeDecodeError):
break
except OSError as e:
logger.debug(f"read_gguf_context_length: cannot open {path}: {e}")
return None
except Exception as e:
logger.debug(f"read_gguf_context_length: parse failure on {path}: {e}")
return None
return None
# Strings (8) and arrays (9) are handled inline.
_FIXED_VTYPE_SIZES: Dict[int, int] = {
0: 1, # uint8

View file

@ -335,7 +335,7 @@ function TauriWrapper({ children }: { children: ReactNode }) {
export function AppProvider({ children }: AppProviderProps) {
return (
<ThemeProvider attribute="class" defaultTheme="light">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TauriWrapper>
{children}

View file

@ -119,6 +119,7 @@ function RootLayout() {
chatRuntime.setActiveThreadId(null);
chatRuntime.setActiveProjectId(null);
chatRuntime.setIncognito(false);
if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel();
void navigate({
to: "/chat",
search: { new: crypto.randomUUID() },
@ -135,6 +136,7 @@ function RootLayout() {
chatRuntime.setActiveProjectId(null);
chatRuntime.setActiveThreadId(null);
chatRuntime.setIncognito(false);
if (chatRuntime.pendingSelection) chatRuntime.abandonStagedModel();
}, [isChatRoute]);
return (

View file

@ -14,6 +14,9 @@ const ModelsPage = lazy(() =>
export interface ModelsSearch {
tab?: "discover" | "downloaded";
model?: string;
section?: "trending" | "latest" | "finetune";
kind?: "models" | "datasets";
}
export const Route = createRoute({
@ -22,8 +25,19 @@ export const Route = createRoute({
beforeLoad: () => requireAuth(),
component: ModelsPage,
validateSearch: (search: Record<string, unknown>): ModelsSearch => {
const next: ModelsSearch = {};
const raw = search.tab;
if (raw === "discover" || raw === "downloaded") return { tab: raw };
return {};
if (raw === "discover" || raw === "downloaded") next.tab = raw;
const model = search.model;
if (typeof model === "string" && model.length > 0) next.model = model;
const section = search.section;
if (section === "trending" || section === "latest" || section === "finetune") {
next.section = section;
}
const kind = search.kind;
if (kind === "models" || kind === "datasets") {
next.kind = kind;
}
return next;
},
});

View file

@ -84,7 +84,7 @@ import {
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, MoreHorizontalIcon, Moon } from "lucide-react";
import { ChevronDown, Moon } from "lucide-react";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import {
archiveChatItem,
@ -226,7 +226,7 @@ function NavItem({
onClick={onClick}
isActive={active}
data-tour={dataTour}
className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-full group-data-[collapsible=icon]:mx-auto"
className="sidebar-nav-btn h-[33px] rounded-[14px] gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-full group-data-[collapsible=icon]:mx-auto"
>
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" />
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span>
@ -293,6 +293,7 @@ export function AppSidebar() {
};
const isRecipesRoute = pathname.startsWith("/data-recipes");
const isExportRoute = pathname === "/export" || pathname.startsWith("/export/");
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
const { projects } = useChatProjects();
@ -334,10 +335,14 @@ export function AppSidebar() {
undefined
: undefined;
// Training runs
// Training runs: surfaced as sidebar "Recents" on Train, Recipes, and Export,
// falling back to chat recents when there are no runs yet.
const trainingRecentsRoute = isStudioRoute || isRecipesRoute || isExportRoute;
const { items: runItems } = useTrainingHistorySidebarItems(
!chatOnly && isStudioRoute,
!chatOnly && trainingRecentsRoute,
);
const showTrainingRecents =
!chatOnly && trainingRecentsRoute && runItems.length > 0;
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
const currentRunViewActive = useTrainingRuntimeStore((s) => s.currentRunViewActive);
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
@ -667,7 +672,7 @@ export function AppSidebar() {
? "sidebar-row-action group-hover/project-chat-item:opacity-100 group-hover/project-chat-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
: "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
const buttonClass = cn(
"sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
"sidebar-nav-btn h-[33px] cursor-pointer rounded-[14px] pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
// pl-3 (12px) over the content's pl-1.5 (6px) = 18px, aligning the
// title with the nav items above.
variant === "project" ? "pl-[39px]" : "pl-3",
@ -921,7 +926,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[33px] cursor-pointer items-center justify-center rounded-full text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
@ -946,7 +951,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[33px] cursor-pointer items-center justify-center rounded-full text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.openSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
@ -1117,7 +1122,7 @@ export function AppSidebar() {
</Collapsible>
{/* Pinned chats: own section above Recents */}
{!isStudioRoute && pinnedChatItems.length > 0 && (
{!isStudioRoute && !showTrainingRecents && pinnedChatItems.length > 0 && (
<Collapsible open={pinnedOpen} onOpenChange={setPinnedOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
@ -1139,7 +1144,7 @@ export function AppSidebar() {
</Collapsible>
)}
{!isStudioRoute && (
{!isStudioRoute && !showTrainingRecents && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
@ -1161,7 +1166,7 @@ export function AppSidebar() {
</Collapsible>
)}
{isStudioRoute && runItems.length > 0 && !chatOnly && (
{showTrainingRecents && (
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
@ -1189,9 +1194,12 @@ export function AppSidebar() {
>
<SidebarMenuButton
isActive={isActiveRun}
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-full pl-3 pr-7 text-[14.5px] tracking-nav font-medium"
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-[14px] pl-3 pr-7 text-[14.5px] tracking-nav font-medium"
onClick={() => {
setSelectedHistoryRunId(run.id);
// From Recipes/Export, jump to Train so the run's
// history opens (studio reacts to selectedHistoryRunId).
if (!isStudioRoute) navigate({ to: "/studio" });
closeMobileIfOpen();
}}
>
@ -1206,7 +1214,7 @@ export function AppSidebar() {
<span className="truncate">
{run.display_name ?? run.model_name}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
<span className="ml-auto mr-0.5 shrink-0 text-[10px] text-muted-foreground">
{formatRelativeShort(run.started_at)}
</span>
</div>
@ -1223,7 +1231,7 @@ export function AppSidebar() {
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
@ -1278,14 +1286,14 @@ export function AppSidebar() {
<SidebarMenuButton
size="lg"
aria-label={t("shell.accountMenu", { name: displayTitle })}
className="sidebar-nav-btn !h-[44px] -my-[3px] gap-[9px] px-2 py-[3px] rounded-[14px]"
className="sidebar-nav-btn !h-[44px] -my-[3px] gap-[9px] px-2 py-[3px] rounded-[14px] group-data-[collapsible=icon]:!size-[34px] group-data-[collapsible=icon]:!rounded-full group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:justify-center"
>
<div className="flex shrink-0 items-center">
<UserAvatar
name={displayTitle}
imageUrl={avatarDataUrl}
size="sm"
className="!size-[32px]"
className="!size-[32px] group-data-[collapsible=icon]:!rounded-full"
/>
</div>
<div className="flex flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">

View file

@ -4,6 +4,13 @@
"use client";
import { ArtifactCard, useChatRuntimeStore } from "@/features/chat";
import {
getCodeFence,
isFullHtmlDocument,
isHtmlFence,
isRenderableRenderHtmlToolPart,
isSvgFence,
} from "@/features/chat/artifacts/html-fences";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link";
@ -45,62 +52,16 @@ const STREAMDOWN_COMPONENTS = {
};
const COPY_RESET_MS = 2000;
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
const ACTION_PANEL_CLASS =
"pointer-events-auto flex shrink-0 items-center gap-1";
const ACTION_BUTTON_CLASS =
"flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50";
type CodeFence = {
language: string | null;
source: string;
};
type ToolCallPartLike = {
type?: string;
toolName?: string;
args?: unknown;
result?: unknown;
};
function isRenderableRenderHtmlToolPart(part: unknown): boolean {
const toolPart = part as ToolCallPartLike;
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Error:")
) {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Rendered HTML canvas")
) {
return true;
}
const args = toolPart.args as { code?: unknown } | undefined;
return typeof args?.code === "string" && args.code.trim().length > 0;
}
function getMermaidSource(blockContent: string): string | null {
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
return source && source.length > 0 ? source : null;
}
function getCodeFence(blockContent: string): CodeFence | null {
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
if (!match) {
return null;
}
return {
language: match[1]?.trim() || null,
source: match[2],
};
}
function getCodeFilename(language: string | null) {
const extByLanguage: Record<string, string> = {
bash: "sh",
@ -131,28 +92,6 @@ function getCodeFilename(language: string | null) {
return `snippet.${ext}`;
}
function isSvgFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
if (lang === "svg") return true;
if (lang === "xml" || lang === "html") {
const trimmed = codeFence.source.trimStart();
// Match <svg directly or <?xml ...?> followed by <svg
if (trimmed.startsWith("<svg")) return true;
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
}
return false;
}
function isHtmlFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
return lang === "html" && !isSvgFence(codeFence);
}
function isFullHtmlDocument(source: string): boolean {
const trimmed = source.trimStart();
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
}
const UNSAFE_SVG_RE =
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
@ -285,15 +224,13 @@ function CodeBlockActions({
);
}
// DiffusionGemma renders its denoising live in the bubble (see DiffusionCanvas in
// thread.tsx) and has the HTML canvas feature on by default, so a full-HTML answer
// (e.g. a playable game) renders as an interactive card without the global toggle.
// Collapse a full-HTML answer in place into an artifact card. Diffusion keeps the
// raw code visible instead (the trailing MessageHtmlArtifacts appends its card).
function StreamdownBlock(props: BlockProps) {
const shouldCollapseHtmlArtifacts = useChatRuntimeStore(
(state) =>
state.artifactsEnabled ||
state.collapseHtmlArtifacts ||
state.loadedIsDiffusion,
(state.artifactsEnabled || state.collapseHtmlArtifacts) &&
!state.loadedIsDiffusion,
);
const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) =>
message.parts.some(isRenderableRenderHtmlToolPart),

View file

@ -8,6 +8,8 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { InfoHint } from "@/components/ui/info-hint";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePlatformStore } from "@/config/env";
import { isCustomProviderType } from "@/features/chat/external-providers";
@ -108,6 +110,11 @@ interface ModelSelectorProps {
activeGgufVariant?: string | null;
onValueChange?: (value: string, meta: ModelSelectorChangeMeta) => void;
onEject?: () => void;
/** When provided, renders a persisted "Load on selection" toggle in the
* popover. Off picking a model stages it for a deferred, configured load
* instead of loading immediately. */
loadOnSelection?: boolean;
onLoadOnSelectionChange?: (value: boolean) => void;
onFoldersChange?: () => void;
onPickLocalModel?: () => void | Promise<void>;
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
@ -210,6 +217,8 @@ function ModelSelectorContent({
onPickLocalModel,
onModelsChange,
deleteDisabled,
loadOnSelection,
onLoadOnSelectionChange,
className,
dataTour,
}: {
@ -223,6 +232,8 @@ function ModelSelectorContent({
onPickLocalModel?: () => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
deleteDisabled?: boolean;
loadOnSelection?: boolean;
onLoadOnSelectionChange?: (value: boolean) => void;
className?: string;
dataTour?: string;
}) {
@ -378,6 +389,37 @@ function ModelSelectorContent({
</button>
</div>
) : null}
{onLoadOnSelectionChange ? (
<div className="mt-1.5 border-t border-border/70 pt-1.5">
<div className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-xs text-muted-foreground">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<span>Load on selection</span>
<InfoHint>
<div className="space-y-1">
<div>
<span className="font-medium">On:</span> load the model
immediately after selection.
</div>
<div>
<span className="font-medium">Off:</span> configure options
first, then click Load model.
</div>
</div>
</InfoHint>
</div>
<span className="text-[10px] leading-none text-muted-foreground/70">
Local GGUF models only
</span>
</div>
<Switch
className="panel-switch shrink-0"
checked={loadOnSelection ?? true}
onCheckedChange={onLoadOnSelectionChange}
/>
</div>
</div>
) : null}
</PopoverContent>
);
}
@ -395,6 +437,8 @@ export function ModelSelector({
onPickLocalModel,
onModelsChange,
deleteDisabled,
loadOnSelection,
onLoadOnSelectionChange,
variant = "outline",
size = "default",
className,
@ -513,6 +557,8 @@ export function ModelSelector({
onPickLocalModel={onPickLocalModel ? handlePickLocalModel : undefined}
onModelsChange={onModelsChange}
deleteDisabled={deleteDisabled}
loadOnSelection={loadOnSelection}
onLoadOnSelectionChange={onLoadOnSelectionChange}
className={contentClassName}
dataTour={contentDataTour}
/>

View file

@ -1357,15 +1357,14 @@ export function HubModelPicker({
<>
<ListLabel>LM Studio</ListLabel>
{lmStudioModels.map((m) => {
const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
const isGguf = isGgufRepo(m.id) || isGgufRepo(m.display_name);
const optionKey = makeModelOptionKey("lm-studio", m.id);
return (
<div key={m.id}>
<ModelRow
label={m.model_id ?? m.display_name}
meta={
isGguf || m.path.toLowerCase().endsWith(".gguf") ? "GGUF" : "Local"
}
meta={isGguf || isGgufFile ? "GGUF" : "Local"}
selected={value === m.id}
optionProps={hubModelList.getOptionProps(
optionKey,
@ -1381,6 +1380,7 @@ export function HubModelPicker({
source: "local",
isLora: false,
isDownloaded: true,
isGguf: isGgufFile,
});
}
}}
@ -1594,6 +1594,7 @@ export function HubModelPicker({
source: "local",
isLora: false,
isDownloaded: true,
isGguf: true,
});
} else if (isGguf) {
setExpandedGguf((prev) =>

View file

@ -31,6 +31,9 @@ export interface ModelSelectorChangeMeta {
ggufVariant?: string;
isDownloaded?: boolean;
expectedBytes?: number;
/** Direct local .gguf file picked without a variant (custom folder / LM
* Studio). Marks it as a GGUF source for the deferred-load staging flow. */
isGguf?: boolean;
}
export interface DeletedModelRef {

View file

@ -20,7 +20,9 @@ import {
} from "@assistant-ui/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { type VariantProps, cva } from "class-variance-authority";
import { CheckIcon, ChevronDownIcon, CopyIcon, LightbulbIcon } from "lucide-react";
import { ChevronDownIcon, CopyIcon, LightbulbIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type CSSProperties,
type ComponentProps,
@ -293,7 +295,7 @@ function ReasoningCopyButton({ startIndex, endIndex }: { startIndex: number; end
aria-label="Copy reasoning"
>
{copied ? (
<CheckIcon className="size-3" />
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3" />
) : (
<CopyIcon className="size-3" />
)}

View file

@ -11,6 +11,7 @@ import {
} from "@/components/assistant-ui/generated-image-overlay-context";
import { downloadImagePart } from "@/components/assistant-ui/image";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { MessageHtmlArtifacts } from "@/components/assistant-ui/message-html-artifacts";
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
import { RagSourcesGroup } from "@/components/assistant-ui/rag-sources";
@ -161,6 +162,7 @@ import {
useState,
} from "react";
import { create } from "zustand";
import { extractTaggedText, updateThreadMessage } from "@/features/chat/utils/update-thread-message";
// True while a file is dragged anywhere over the chat page, so the composer
// can show its "Drop files here" affordance.
@ -3194,40 +3196,138 @@ const DiffusionCanvas: FC = () => {
);
};
/**
* AssistantMessage handles the display and inline-editing of AI responses.
*
* It utilizes a "Tagged Text" system (<THINK> and <TOOL> tags) to allow users
* to edit structured reasoning and tool outputs within a plain-text textarea
* while preserving the underlying data schema and tool-call metadata.
*/
const AssistantMessage: FC = () => {
const aui = useAui();
const messageId = useAuiState(({ message }) => message.id);
const messageContent = useAuiState(({ message }) => message.content);
const incognito = useChatRuntimeStore((s) => s.incognito);
// Use global store for editing state to ensure a single source of truth
const editingId = useChatRuntimeStore((s) => s.editingMessageId);
const setEditingId = useChatRuntimeStore((s) => s.setEditingMessageId);
const isEditing = editingId === messageId;
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-grow textarea height based on content
const adjustHeight = () => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
};
useEffect(() => {
if (isEditing) setTimeout(adjustHeight, 0);
}, [isEditing]);
const handleSave = async () => {
const finalText = textareaRef.current?.value || "";
// Prioritize the specific thread item ID, then fallback to the global active thread ID
const remoteId = aui.threadListItem().getState().remoteId
|| useChatRuntimeStore.getState().activeThreadId;
if (!remoteId || remoteId === "" || remoteId === "/") {
toast.error("Save failed: No thread ID found.");
setEditingId(null);
return;
}
try {
await updateThreadMessage({
thread: {
export: () => aui.thread().export(),
import: (data) => aui.thread().import(data)
},
messageId,
remoteId,
newText: finalText,
isIncognito: incognito,
});
} catch (error) {
console.error("UI: Error during save:", error);
toast.error("Failed to save message edits.");
} finally {
setEditingId(null);
}
};
return (
<MessagePrimitive.Root
className="aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-[#0d0d0d] dark:text-foreground leading-relaxed">
<GeneratingIndicator />
<CancelledIndicator />
<DiffusionCanvas />
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: Reasoning,
ReasoningGroup: ReasoningGroup,
Source: Sources,
ToolGroup: ToolGroup,
tools: {
by_name: {
web_search: WebSearchToolUIConfirmable,
search_knowledge_base: KnowledgeBaseToolUIConfirmable,
python: PythonToolUIConfirmable,
terminal: TerminalToolUIConfirmable,
code_execution: CodeExecutionToolUIConfirmable,
image_generation: ImageGenerationToolUIConfirmable,
render_html: RenderHtmlToolUIConfirmable,
},
Fallback: ToolFallbackConfirmable,
},
}}
/>
<SourcesGroup />
<RagSourcesGroup />
<MessageError />
{isEditing ? (
<div className="flex flex-col gap-2 w-full">
<textarea
ref={textareaRef}
defaultValue={extractTaggedText(messageContent)}
className="w-full p-3 rounded-xl bg-muted border border-border text-foreground focus:ring-2 focus:ring-primary outline-none overflow-y-auto resize-none font-mono text-sm max-h-[70vh]"
autoFocus
onInput={adjustHeight}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleSave();
}
if (e.key === 'Escape') {
setEditingId(null); // UX: Close editor on Escape
}
}}
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)} className="h-8 text-xs">Cancel</Button>
<Button size="sm" onClick={handleSave} className="h-8 text-xs">Save</Button>
</div>
</div>
) : (
<>
<GeneratingIndicator />
<CancelledIndicator />
<DiffusionCanvas />
{/*
We use the standard MessagePrimitive.Parts. This ensures that
edited messages maintain the same professional styling,
Markdown rendering, and tool-call components as original responses.
*/}
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: Reasoning,
ReasoningGroup: ReasoningGroup,
Source: Sources,
ToolGroup: ToolGroup,
tools: {
by_name: {
web_search: WebSearchToolUIConfirmable,
search_knowledge_base: KnowledgeBaseToolUIConfirmable,
python: PythonToolUIConfirmable,
terminal: TerminalToolUIConfirmable,
code_execution: CodeExecutionToolUIConfirmable,
image_generation: ImageGenerationToolUIConfirmable,
render_html: RenderHtmlToolUIConfirmable,
},
Fallback: ToolFallbackConfirmable,
},
}}
/>
<SourcesGroup />
<RagSourcesGroup />
<MessageHtmlArtifacts />
<MessageError />
</>
)}
</div>
<div className="aui-assistant-message-footer mt-1.5 -ml-[var(--icon-btn-inset)] flex min-h-8">
@ -3414,6 +3514,26 @@ const CopyButton: FC = () => {
);
};
const EditAssistantMessageButton: FC = () => {
const messageId = useAuiState(({ message }) => message.id);
const isRunning = useAuiState(({ thread }) => thread.isRunning);
const setEditingId = useChatRuntimeStore((s) => s.setEditingMessageId);
return (
<TooltipIconButton
tooltip="Edit response"
disabled={isRunning}
onClick={() => setEditingId(messageId)}
>
<HugeiconsIcon
icon={Edit03Icon}
strokeWidth={1.75}
className="size-icon"
/>
</TooltipIconButton>
);
};
const AssistantActionBar: FC = () => {
const { forkMessage, forkDisabled } = useForkMessageAction();
@ -3423,6 +3543,7 @@ const AssistantActionBar: FC = () => {
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-full [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
>
<CopyButton />
<EditAssistantMessageButton />
<ActionBarPrimitive.Reload asChild={true}>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
@ -3455,7 +3576,11 @@ const AssistantActionBar: FC = () => {
</ActionBarMorePrimitive.Item>
<ActionBarPrimitive.ExportMarkdown asChild={true}>
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon" />
<HugeiconsIcon
icon={Download01Icon}
strokeWidth={1.75}
className="size-icon"
/>
Export as Markdown
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>

View file

@ -17,11 +17,12 @@ import {
} from "@assistant-ui/react";
import {
AlertCircleIcon,
CheckIcon,
ChevronDownIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type CSSProperties,
type ComponentProps,
@ -95,9 +96,15 @@ function ToolFallbackRoot({
type ToolStatus = ToolCallMessagePartStatus["type"];
// The shared app tick is icon data, not a component; wrap it to slot into the
// status map alongside the lucide icons.
function CompleteTickIcon(props: Omit<ComponentProps<typeof HugeiconsIcon>, "icon">) {
return <HugeiconsIcon icon={Tick02Icon} strokeWidth={2} {...props} />;
}
const statusIconMap: Record<ToolStatus, ElementType> = {
running: LoaderIcon,
complete: CheckIcon,
complete: CompleteTickIcon,
incomplete: XCircleIcon,
"requires-action": AlertCircleIcon,
};

View file

@ -8,12 +8,9 @@ import {
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import {
CheckIcon,
CopyIcon,
FileTextIcon,
TerminalIcon,
} from "lucide-react";
import { CopyIcon, FileTextIcon, TerminalIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@ -93,7 +90,7 @@ function CopyBtn({ text }: { text: string }) {
aria-label="Copy to clipboard"
>
{copied ? (
<CheckIcon className="size-3" />
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3" />
) : (
<CopyIcon className="size-3" />
)}

View file

@ -7,7 +7,9 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { getAuthToken } from "@/features/auth/session";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CheckIcon, CodeIcon, CopyIcon } from "lucide-react";
import { CodeIcon, CopyIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
@ -63,7 +65,7 @@ function CopyBtn({ text }: { text: string }) {
aria-label="Copy to clipboard"
>
{copied ? (
<CheckIcon className="size-3" />
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3" />
) : (
<CopyIcon className="size-3" />
)}

View file

@ -5,7 +5,9 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, CopyIcon, TerminalIcon } from "lucide-react";
import { CopyIcon, TerminalIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
@ -53,7 +55,7 @@ function CopyBtn({ text }: { text: string }) {
aria-label="Copy to clipboard"
>
{copied ? (
<CheckIcon className="size-3" />
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3" />
) : (
<CopyIcon className="size-3" />
)}

View file

@ -7,10 +7,7 @@ import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { Download } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement, useEffect, useRef, useState } from "react";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
// Backend progress is coarse (5% steps, ~0.9 max) and the extract tail emits no
// signal. Creep toward this cap so the bar keeps moving rather than freezing.
const RUNNING_CAP = 0.95;
@ -114,6 +111,12 @@ export function LlamaUpdateBanner({
const show =
visible && status != null && (status.update_available || applying);
const sizeBytes = status?.update_size_bytes ?? null;
// Round to whole MB; these prebuilts are hundreds of MB.
const sizeLabel =
sizeBytes && sizeBytes > 0
? `${Math.round(sizeBytes / (1024 * 1024))} MB`
: null;
const updateProgress = status?.job.progress ?? null;
const jobSucceeded = status?.job.state === "success";
// Drives the bar so it animates continuously; aria reports the real value.
@ -123,113 +126,110 @@ export function LlamaUpdateBanner({
jobSucceeded,
);
return (
<AnimatePresence>
{show ? (
<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={cn(
positioned
? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]"
: "pointer-events-auto w-full",
)}
data-testid="llama-update-banner"
>
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
{applying ? null : (
<button
type="button"
onClick={dismiss}
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss llama.cpp update notification"
>
<svg
aria-hidden="true"
width="12"
height="12"
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 min-w-0 items-start gap-4 pr-6">
<Download
aria-hidden="true"
className="mt-1 size-5 shrink-0 text-foreground"
strokeWidth={1.75}
// Render with no enter/exit animation. An opacity/transform transition (in or
// out) promotes a GPU compositing layer whose creation or teardown can flash
// for a frame on real displays, which reads as a flicker on appear and on
// dismiss. A plain conditional mount appears and leaves cleanly.
return show ? (
<div
className={cn(
positioned
? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]"
: "pointer-events-auto w-full",
)}
data-testid="llama-update-banner"
>
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
{applying ? null : (
<button
type="button"
onClick={dismiss}
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss llama.cpp update notification"
>
<svg
aria-hidden="true"
width="12"
height="12"
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"
/>
<div className="min-w-0">
<p className="font-heading text-base font-medium text-foreground">
{applying ? "Updating llama.cpp..." : "New llama.cpp update"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{status?.installed_tag ?? "unknown"} &rarr;{" "}
<span className="font-medium text-foreground">
{status?.latest_tag ?? ""}
</span>
</p>
<p className="mt-1 text-[11px] text-muted-foreground/70">
No restart needed after update
</p>
</div>
</div>
</svg>
</button>
)}
{applying ? (
<div
className="mb-1.5 mt-4 h-1 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="Updating llama.cpp"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
updateProgress != null
? Math.round(updateProgress * 100)
: Math.round(displayProgress * 100)
}
data-testid="llama-update-progress"
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.max(displayProgress * 100, 2)}%` }}
/>
</div>
) : (
<div className="mt-2 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
onClick={snooze}
data-testid="llama-update-snooze-button"
>
Remind me later
</Button>
<Button
size="sm"
// -mr optically aligns the filled pill's edge with the card padding
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleUpdate}
data-testid="llama-update-button"
>
Update
</Button>
</div>
)}
<div className="flex min-w-0 items-start gap-4 pr-6">
<Download
aria-hidden="true"
className="mt-1 size-5 shrink-0 text-foreground"
strokeWidth={1.75}
/>
<div className="min-w-0">
<p className="font-heading text-base font-medium text-foreground">
{applying ? "Updating llama.cpp..." : "New llama.cpp update"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{status?.installed_tag ?? "unknown"} &rarr;{" "}
<span className="font-medium text-foreground">
{status?.latest_tag ?? ""}
</span>
</p>
<p className="mt-1 text-[11px] text-muted-foreground/70">
{sizeLabel ? `${sizeLabel} download · ` : ""}No restart needed
after update
</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
</div>
{applying ? (
<div
className="mb-1.5 mt-4 h-1 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="Updating llama.cpp"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
updateProgress != null
? Math.round(updateProgress * 100)
: Math.round(displayProgress * 100)
}
data-testid="llama-update-progress"
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.max(displayProgress * 100, 2)}%` }}
/>
</div>
) : (
<div className="mt-2 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
onClick={snooze}
data-testid="llama-update-snooze-button"
>
Remind me later
</Button>
<Button
size="sm"
// -mr optically aligns the filled pill's edge with the card padding
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleUpdate}
data-testid="llama-update-button"
>
Update
</Button>
</div>
)}
</div>
</div>
) : null;
}

View file

@ -7,7 +7,7 @@
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import * as React from "react";
import { createContext, useContext, useState } from "react";
import { createContext, useState } from "react";
import { Button } from "@/components/ui/button";
import { useDialogPortalContainer } from "@/components/ui/dialog";
@ -18,11 +18,9 @@ import {
InputGroupInput,
} from "@/components/ui/input-group";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
Cancel01Icon,
} from "@hugeicons/core-free-icons";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const ComboboxOpenContext = createContext(false);
@ -68,7 +66,7 @@ function ComboboxTrigger({
>
{children}
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={2}
className="text-muted-foreground size-4 pointer-events-none"
/>
@ -107,18 +105,8 @@ function ComboboxInput({
showTrigger?: boolean;
showClear?: boolean;
}): React.ReactElement {
const isOpen = useContext(ComboboxOpenContext);
return (
<InputGroup
className={cn("w-auto", className)}
style={{
borderRadius: isOpen ? "12px" : undefined,
transition: isOpen
? "border-radius 0ms"
: "border-radius 150ms cubic-bezier(0.645, 0.045, 0.355, 1)",
}}
>
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
@ -130,7 +118,7 @@ function ComboboxInput({
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
className="group-has-data-[slot=combobox-clear]/input-group:hidden bg-transparent hover:bg-transparent data-pressed:bg-transparent aria-expanded:bg-transparent dark:hover:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
@ -209,7 +197,7 @@ function ComboboxItem({
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-xl corner-squircle py-2 pr-2 pl-3 text-sm [&[aria-selected=true]]:pr-7 [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-[10px] py-2 pr-2 pl-3 text-sm [&[aria-selected=true]]:pr-7 [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}

View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InformationCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
/** Small "i" affordance that reveals a styled tooltip on hover/focus. The
* standard inline help control across the settings UI. */
export function InfoHint({ children }: { children: ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="More info"
className="inline-flex size-4 shrink-0 cursor-help items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-[#383835] dark:hover:text-[#e8e8e8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={InformationCircleIcon}
strokeWidth={1.75}
className="size-3.5"
/>
</button>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={6}
collisionPadding={12}
className="[&_span>svg]:hidden! duration-0 max-w-[240px] text-left"
>
{children}
</TooltipContent>
</Tooltip>
);
}

View file

@ -8,6 +8,7 @@ import type * as React from "react";
import { createContext, useContext, useState } from "react";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { useDialogPortalContainer } from "@/components/ui/dialog";
import {
@ -98,7 +99,7 @@ function SelectTrigger({
{children}
<SelectPrimitive.Icon asChild>
<HugeiconsIcon
icon={icon ?? UnfoldMoreIcon}
icon={icon ?? ChevronDownStandardIcon}
strokeWidth={2}
className={cn(
"text-muted-foreground size-4 pointer-events-none",

View file

@ -12,73 +12,100 @@ import { Spinner } from "@/components/ui/spinner";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
// Make toast text selectable. Sonner's onPointerDown calls setPointerCapture(),
// which steals the drag and blocks text selection. dismissible:false would stop
// it but also kills the close button. So we swallow pointerdown on toast text
// (never on its buttons) before sonner sees it.
const handleToastPointerDownCapture = (
event: React.PointerEvent<HTMLDivElement>,
) => {
// closest() lives on Element, so this also covers SVG icon targets; guard
// against non-Element targets defensively.
const target = event.target as Element | null;
if (typeof target?.closest !== "function") return;
if (!target.closest("[data-sonner-toast]")) return;
if (
target.closest("button,[data-button],[data-close-button],[data-cancel]")
) {
return;
}
event.stopPropagation();
};
const Toaster = ({ ...props }: ToasterProps) => {
// Use resolvedTheme so sonner's data-sonner-theme always matches the class
// next-themes puts on <html>; sonner-side "system" resolution can drift.
const { resolvedTheme } = useTheme();
return (
<Sonner
theme={(resolvedTheme as ToasterProps["theme"]) ?? "light"}
className="toaster group"
duration={5000}
icons={{
success: (
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
strokeWidth={2}
className="size-4"
/>
),
info: (
<HugeiconsIcon
icon={InformationCircleIcon}
strokeWidth={2}
className="size-4"
/>
),
warning: (
<HugeiconsIcon
icon={Alert02Icon}
strokeWidth={2}
className="size-4"
/>
),
error: (
<HugeiconsIcon
icon={MultiplicationSignCircleIcon}
strokeWidth={2}
className="size-4"
/>
),
// App-wide arc spinner so loading toasts match the "Downloading model" toast.
loading: <Spinner className="size-4 text-muted-foreground" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
// No border line; elevation comes from the composer's drop shadow.
"--normal-border": "transparent",
"--border-radius": "var(--radius)",
// Pin the close button inside the toast's top-right corner.
// Sonner defaults to the left/outside edge, so keep the horizontal
// override here and the top offset in index.css.
"--toast-close-button-start": "unset",
"--toast-close-button-end": "8px",
"--toast-close-button-transform": "none",
} as React.CSSProperties
}
// No swipe gestures; keeps toast text selectable.
swipeDirections={[]}
toastOptions={{
classNames: {
toast: "cn-toast",
description: "!text-muted-foreground",
},
}}
{...props}
/>
// display:contents adds no box; only carries the selection-fix handler.
// biome-ignore lint/a11y/noStaticElementInteractions: capture-only guard, not interactive
<div
style={{ display: "contents" }}
onPointerDownCapture={handleToastPointerDownCapture}
>
<Sonner
theme={(resolvedTheme as ToasterProps["theme"]) ?? "light"}
className="toaster group"
duration={5000}
icons={{
success: (
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
strokeWidth={2}
className="size-4"
/>
),
info: (
<HugeiconsIcon
icon={InformationCircleIcon}
strokeWidth={2}
className="size-4"
/>
),
warning: (
<HugeiconsIcon
icon={Alert02Icon}
strokeWidth={2}
className="size-4"
/>
),
error: (
<HugeiconsIcon
icon={MultiplicationSignCircleIcon}
strokeWidth={2}
className="size-4"
/>
),
// App-wide arc spinner so loading toasts match the "Downloading model" toast.
loading: <Spinner className="size-4 text-muted-foreground" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
// No border line; elevation comes from the composer's drop shadow.
"--normal-border": "transparent",
"--border-radius": "var(--radius)",
// Pin the close button inside the toast's top-right corner.
// Sonner defaults to the left/outside edge, so keep the horizontal
// override here and the top offset in index.css.
"--toast-close-button-start": "unset",
"--toast-close-button-end": "8px",
"--toast-close-button-transform": "none",
} as React.CSSProperties
}
// No swipe gestures; text selection handled by the wrapper above.
swipeDirections={[]}
toastOptions={{
classNames: {
toast: "cn-toast",
description: "!text-muted-foreground",
},
}}
{...props}
/>
</div>
);
};

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { consumeNativePathToken } from "@/features/native-intents/api";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
import type {
MessageRecord,
@ -10,7 +11,9 @@ import type {
ThreadRecord,
} from "../types";
import type {
ApiMonitorEntry,
AudioGenerationResponse,
ApiMonitorResponse,
GgufVariantsResponse,
InferenceStatusResponse,
ListLorasResponse,
@ -70,6 +73,18 @@ export async function getInferenceStatus(): Promise<InferenceStatusResponse> {
return parseJsonOrThrow<InferenceStatusResponse>(response);
}
export async function getApiMonitor(): Promise<ApiMonitorResponse> {
const response = await authFetch("/api/inference/monitor");
return parseJsonOrThrow<ApiMonitorResponse>(response);
}
export async function getApiMonitorEntry(id: string): Promise<ApiMonitorEntry> {
const response = await authFetch(
`/api/inference/monitor/${encodeURIComponent(id)}`,
);
return parseJsonOrThrow<ApiMonitorEntry>(response);
}
export async function loadModel(
payload: LoadModelRequest,
): Promise<LoadModelResponse> {
@ -101,6 +116,45 @@ export async function validateModel(
return parseJsonOrThrow<ValidateModelResponse>(response);
}
/**
* Read a GGUF's native context length from its local header (no GPU load, no
* download). Returns null when the file isn't downloaded yet, the model isn't a
* GGUF, or it's gated. For a native (drag-drop / picked) file, pass
* `nativePathToken` so the backend reads the granted local path. Used by the
* deferred-load staging flow to fill the context slider before the single load.
*/
export async function fetchGgufContextLength(payload: {
model_path: string;
gguf_variant?: string | null;
hf_token?: string | null;
nativePathToken?: string | null;
}): Promise<number | null> {
let nativePathLease: string | null = null;
if (payload.nativePathToken) {
try {
nativePathLease = (
await consumeNativePathToken(payload.nativePathToken, "validate-model")
).nativePathLease;
} catch {
// Lease expired / revoked: degrade to no context (the load can re-mint).
return null;
}
}
const response = await authFetch("/api/inference/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_path: payload.model_path,
gguf_variant: payload.gguf_variant ?? null,
hf_token: payload.hf_token ?? null,
native_path_lease: nativePathLease,
include_context_length: true,
}),
});
const res = await parseJsonOrThrow<ValidateModelResponse>(response);
return res.context_length ?? null;
}
export async function unloadModel(payload: UnloadModelRequest): Promise<void> {
const response = await authFetch("/api/inference/unload", {
method: "POST",

View file

@ -3,12 +3,14 @@
"use client";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import { cn } from "@/lib/utils";
import { useAuiState } from "@assistant-ui/react";
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useLayoutEffect, useMemo } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { ArtifactViewMode } from "./html-frame";
import {
hasAutoOpenedArtifact,
rememberAutoOpenedArtifact,
@ -20,6 +22,9 @@ import {
createChatArtifact,
} from "./types";
const CARD_BASE =
"group/artifact-card relative flex min-h-[52px] cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:bg-muted/10 dark:hover:bg-muted/20";
export function ArtifactCard({
code,
title,
@ -40,6 +45,11 @@ export function ArtifactCard({
isStreaming?: boolean;
}) {
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
// Canvas mode collapses the raw code in place, so offer a Code button too.
// Diffusion keeps its code inline, so it needs no Code button.
const showCodeButton = useChatRuntimeStore(
(state) => state.artifactsEnabled && !state.loadedIsDiffusion,
);
const messageIdFromContext = useAuiState(({ message }) => message.id);
const threadIdFromContext = useAuiState(
({ threads }) => threads.mainThreadId,
@ -87,7 +97,7 @@ export function ArtifactCard({
}
rememberAutoOpenedArtifact(artifact.id);
openArtifact(artifact, { surface });
openArtifact(artifact, { surface, view: "preview" });
}, [
artifact,
autoOpen,
@ -97,45 +107,63 @@ export function ArtifactCard({
updateArtifact,
]);
return (
<button
type="button"
className={cn(
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"dark:bg-muted/10 dark:hover:bg-muted/20",
isStreaming &&
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
className,
)}
onClick={() => openArtifact(artifact, { surface })}
aria-label={`Open ${artifact.title}`}
>
{isStreaming ? (
<span
aria-hidden={true}
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
/>
) : null}
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={Layout2ColumnIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{artifact.title}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
HTML canvas
</span>
</span>
const renderButton = (view: ArtifactViewMode) => {
const isCode = view === "source";
return (
<button
key={view}
type="button"
className={cn(
CARD_BASE,
showCodeButton ? "min-w-0 flex-1" : "w-full max-w-md",
isStreaming &&
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
)}
onClick={() => openArtifact(artifact, { surface, view })}
aria-label={`Open ${artifact.title} ${isCode ? "code" : "preview"}`}
>
{isStreaming ? (
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
Generating
</span>
<span
aria-hidden={true}
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
/>
) : null}
</div>
</button>
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
{isCode ? (
<CodeToggleIcon className="size-5 shrink-0 text-muted-foreground" />
) : (
<HugeiconsIcon
icon={Layout2ColumnIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
)}
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{isCode ? "HTML Code" : artifact.title}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
HTML canvas
</span>
</span>
{isStreaming && !isCode ? (
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
Generating
</span>
) : null}
</div>
</button>
);
};
if (!showCodeButton) {
return <div className={cn("my-2", className)}>{renderButton("preview")}</div>;
}
return (
<div className={cn("my-2 flex w-full max-w-xl gap-2", className)}>
{renderButton("preview")}
{renderButton("source")}
</div>
);
}

View file

@ -13,14 +13,9 @@ import { MascotImg } from "@/components/mascot-img";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import {
CheckIcon,
CopyIcon,
EyeIcon,
Maximize2Icon,
XIcon,
} from "lucide-react";
import { CopyIcon, EyeIcon, Maximize2Icon, XIcon } from "lucide-react";
import { Download01Icon } from "@hugeicons/core-free-icons";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type KeyboardEvent,
@ -31,6 +26,7 @@ import {
} from "react";
import { Streamdown } from "streamdown";
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
import { useChatArtifactsStore } from "./store";
import type { ChatArtifact } from "./types";
import { getArtifactFilename } from "./types";
@ -119,6 +115,8 @@ export function ArtifactSurface({
onOpenFullscreen?: () => void;
}) {
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
// Follow the view the opener asked for (Preview vs Code button), per artifact.
const requestedView = useChatArtifactsStore((state) => state.requestedView);
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const surfaceRef = useRef<HTMLElement>(null);
@ -138,6 +136,10 @@ export function ArtifactSurface({
};
}, []);
useEffect(() => {
setViewMode(requestedView);
}, [artifact.id, requestedView]);
useEffect(() => {
if (variant !== "overlay") return;
previousFocusRef.current = document.activeElement;
@ -278,7 +280,7 @@ export function ArtifactSurface({
aria-label="Copy canvas HTML"
>
{copied ? (
<CheckIcon className="size-4" />
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-4" />
) : (
<CopyIcon className="size-4" />
)}

View file

@ -0,0 +1,145 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Shared fenced-code helpers for the HTML-artifact render paths, hoisted from
// markdown-text.tsx so the in-place collapse and the post-message auto-render
// agree on what counts as a renderable HTML fence.
export type CodeFence = {
language: string | null;
source: string;
};
// Matches one fenced block spanning the whole string (one pre-split block).
export const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
export type ToolCallPartLike = {
type?: string;
toolName?: string;
args?: unknown;
result?: unknown;
};
// True when a part is a render_html tool call with usable code or a non-error result.
export function isRenderableRenderHtmlToolPart(part: unknown): boolean {
const toolPart = part as ToolCallPartLike;
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Error:")
) {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Rendered HTML canvas")
) {
return true;
}
const args = toolPart.args as { code?: unknown } | undefined;
return typeof args?.code === "string" && args.code.trim().length > 0;
}
export function getCodeFence(blockContent: string): CodeFence | null {
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
if (!match) {
return null;
}
return {
language: match[1]?.trim() || null,
source: match[2],
};
}
export function isSvgFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
if (lang === "svg") return true;
if (lang === "xml" || lang === "html") {
const trimmed = codeFence.source.trimStart();
// <svg directly, or <?xml ...?> then <svg
if (trimmed.startsWith("<svg")) return true;
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
}
return false;
}
export function isHtmlFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
return lang === "html" && !isSvgFence(codeFence);
}
export function isFullHtmlDocument(source: string): boolean {
const trimmed = source.trimStart();
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
}
export interface HtmlFence {
source: string;
isFullDocument: boolean;
// Plain 3-backtick unindented fence: the only form the in-place collapser
// (CODE_FENCE_RE) recognizes, so only these may be skipped as already shown.
isPlainFence: boolean;
index: number;
}
// Opening fence: up to 3 leading spaces, >=3 backticks, then a backtick-free info string.
const FENCE_OPEN_RE = /^( {0,3})(`{3,})([^`\r\n]*)$/;
// Scan a full message for every closed ```html fence. Line-based so multiple
// fences are found and backticks inside a <script> string never split a block
// (a close must be its own fence line). Drops unterminated/SVG fences.
export function extractHtmlFences(text: string): HtmlFence[] {
const lines = text.split(/\r?\n/);
const fences: HtmlFence[] = [];
let i = 0;
let index = 0;
while (i < lines.length) {
const open = lines[i].match(FENCE_OPEN_RE);
if (!open) {
i++;
continue;
}
const indent = open[1].length;
const ticks = open[2].length;
const lang = open[3].trim().split(/\s+/)[0]?.toLowerCase() ?? "";
const closeRe = new RegExp(`^ {0,3}\`{${ticks},}\\s*$`);
// Strip up to `indent` leading spaces (CommonMark fence indentation).
const indentRe = indent > 0 ? new RegExp(`^ {0,${indent}}`) : null;
let j = i + 1;
const body: string[] = [];
let closed = false;
while (j < lines.length) {
if (closeRe.test(lines[j])) {
closed = true;
break;
}
body.push(indentRe ? lines[j].replace(indentRe, "") : lines[j]);
j++;
}
if (!closed) {
break; // everything after an unterminated open fence is inside it
}
if (lang === "html") {
const source = body.join("\n");
if (!isSvgFence({ language: "html", source })) {
fences.push({
source,
isFullDocument: isFullHtmlDocument(source),
isPlainFence: indent === 0 && ticks === 3,
index: index++,
});
}
}
i = j + 1;
}
return fences;
}

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import type { ArtifactViewMode } from "./html-frame";
import type { ChatArtifact, ChatArtifactSurface } from "./types";
const autoOpenedArtifactIds = new Set<string>();
@ -22,9 +23,11 @@ type ChatArtifactsState = {
artifactsById: Record<string, ChatArtifact>;
selectedArtifactId: string | null;
surface: ChatArtifactSurface;
// View the surface should show on the next open (Preview vs Code button).
requestedView: ArtifactViewMode;
openArtifact: (
artifact: ChatArtifact,
options?: { surface?: ChatArtifactSurface },
options?: { surface?: ChatArtifactSurface; view?: ArtifactViewMode },
) => void;
updateArtifact: (artifact: ChatArtifact) => void;
closeArtifactSurface: () => void;
@ -37,6 +40,7 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
requestedView: "preview",
openArtifact: (artifact, options) =>
set((state) => ({
artifactsById: {
@ -45,6 +49,7 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
},
selectedArtifactId: artifact.id,
surface: options?.surface ?? state.surface,
requestedView: options?.view ?? "preview",
})),
updateArtifact: (artifact) =>
set((state) =>

View file

@ -62,7 +62,10 @@ import {
parseExternalModelId,
} from "./external-providers";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import type { SelectedModelInput } from "./hooks/use-chat-model-runtime";
import { useChatProjects } from "./hooks/use-chat-projects";
import { useStagedModelPreparation } from "./hooks/use-staged-model-preparation";
import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
import {
type SidebarItem,
useChatSidebarItems,
@ -93,6 +96,7 @@ import {
CHAT_IMAGE_TOOLS_ENABLED_KEY,
CHAT_TOOLS_ENABLED_KEY,
CHAT_WEB_FETCH_TOOLS_ENABLED_KEY,
hasGgufSource,
loadOptionalBool,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
@ -1024,6 +1028,20 @@ export function ChatPage(): ReactElement {
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
const loadOnSelection = useChatRuntimeStore((s) => s.loadOnSelection);
const setLoadOnSelection = useChatRuntimeStore((s) => s.setLoadOnSelection);
// Deferred-load staging: downloads a staged GGUF (if needed) and reads its
// header context so the sheet can show the context slider before the load.
const stagedDownload = useStagedModelPreparation();
// Abandon a staged pick: the store action cancels its in-flight download and
// reverts the edited knobs, so nothing lingers after the user walks away.
const abandonStaged = useCallback(() => {
useChatRuntimeStore.getState().abandonStagedModel();
}, []);
// Tracks whether the chat page is still mounted, so a staged-load failure that
// resolves after the user left chat doesn't resurrect the abandoned pick.
const mountedRef = useRef(true);
useEffect(() => () => void (mountedRef.current = false), []);
const incognito = useChatRuntimeStore((s) => s.incognito);
const setIncognito = useChatRuntimeStore((s) => s.setIncognito);
const incognitoLabel = incognito
@ -1503,12 +1521,64 @@ export function ChatPage(): ReactElement {
closeArtifactSurface();
}, [activeThreadId, closeArtifactSurface, selectedArtifact, view]);
// Abandon a staged (not-yet-loaded) pick when the chat context actually
// changes — switching threads, leaving single view, or starting a new chat /
// project — so a stale Load button can't resurface in a different context.
// New Chat keeps activeThreadId null and only bumps the `new` search nonce, so
// the key includes the route identity, not just the thread. Mirrors the
// incognito reset pattern. (Route exit is handled in __root.tsx, which runs
// after this unmounts.) Clear only on a real change, never on mount: staging
// from the Hub sets pendingSelection then navigates here, and clearing on
// mount would wipe it. Comparing the previous context (rather than a first-run
// flag) is also safe under StrictMode's double-invoke and component remounts.
const chatContextKey = `${view.mode}|${activeThreadId ?? ""}|${search.new ?? ""}|${search.project ?? ""}`;
const chatContextKeyRef = useLatestRef(chatContextKey);
const prevChatContextRef = useRef<string | null>(null);
useEffect(() => {
const prev = prevChatContextRef.current;
prevChatContextRef.current = chatContextKey;
if (prev === null || prev === chatContextKey) return;
abandonStaged();
}, [chatContextKey, abandonStaged]);
const hasActiveModel = Boolean(inferenceParams.checkpoint);
// Load immediately, or — when "Load on selection" is off — stage the pick so
// its load options can be set first. Shared by the main selector, native
// drag-drop/picker, and the dropped-file chip (the Hub stages via the store).
const stageOrLoad = useCallback(
async (selection: SelectedModelInput) => {
const store = useChatRuntimeStore.getState();
// Only GGUF picks have pre-load options worth staging. Non-GGUF models
// (and the toggle-on case) load immediately, so e.g. a trust_remote_code
// approval surfaces through the normal load path.
if (store.loadOnSelection || !hasGgufSource(selection)) {
// Abandon any staged GGUF first so its edited knobs (e.g. a custom
// context length) don't leak into this immediate load -- resolveLoad
// reads customContextLength before checking the target is GGUF.
abandonStaged();
await selectModel(selection);
return;
}
// Tear down any existing staged pick first so its in-flight download is
// cancelled, not left running after we rebind to the new pick.
abandonStaged();
store.stageModel({
id: selection.id,
isLora: selection.isLora,
ggufVariant: selection.ggufVariant,
isDownloaded: selection.isDownloaded,
expectedBytes: selection.expectedBytes,
nativePathToken: selection.nativePathToken,
isGguf: selection.isGguf,
});
},
[abandonStaged, selectModel],
);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
const label =
intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
await selectModel({
await stageOrLoad({
id: label,
nativePathToken: intent.path.token,
isDownloaded: true,
@ -1518,7 +1588,7 @@ export function ChatPage(): ReactElement {
});
useNativeIntentStore.getState().clearModelIntent(intent.id);
},
[selectModel],
[stageOrLoad],
);
const handleNativeModelDropAutoLoad = useCallback(
(intent: NativeIntent) =>
@ -1567,6 +1637,7 @@ export function ChatPage(): ReactElement {
ggufVariant?: string;
isDownloaded?: boolean;
expectedBytes?: number;
isGguf?: boolean;
},
) => {
const store = useChatRuntimeStore.getState();
@ -1579,6 +1650,9 @@ export function ChatPage(): ReactElement {
)
return;
if (meta?.source === "external" || isExternalModelId(value)) {
// Switching to an external model abandons any staged local pick: cancel
// its download too (setCheckpoint below only clears the pending + knobs).
abandonStaged();
const selectedExternal = parseExternalModelId(value);
const selectedProvider = selectedExternal
? externalProvidersForChat.find(
@ -1746,20 +1820,27 @@ export function ChatPage(): ReactElement {
duration: 6000,
});
}
await selectModel({
const selection = {
id: value,
isLora: meta?.isLora,
ggufVariant: meta?.ggufVariant,
isDownloaded: meta?.isDownloaded,
expectedBytes: meta?.expectedBytes,
});
isGguf: meta?.isGguf,
};
// "Load on selection" off: stage the model and open settings so its
// load knobs (tensor parallel, context length…) can be set, then it
// loads once via the sheet's Load button. The currently loaded model
// stays put until the user commits.
await stageOrLoad(selection);
})();
},
[
abandonStaged,
activeThreadId,
externalProvidersForChat,
modelsFromStore,
selectModel,
stageOrLoad,
view,
],
);
@ -2139,6 +2220,8 @@ export function ChatPage(): ReactElement {
activeGgufVariant={activeGgufVariant}
onValueChange={handleCheckpointChange}
onEject={handleEject}
loadOnSelection={loadOnSelection}
onLoadOnSelectionChange={setLoadOnSelection}
onFoldersChange={refreshLocalModels}
onPickLocalModel={isTauri ? chooseNativeModel : undefined}
onModelsChange={refreshModelLists}
@ -2152,16 +2235,6 @@ export function ChatPage(): ReactElement {
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[34px]"
/>
)}
{incognito && view.mode === "single" && (
<div className="flex h-[34px] shrink-0 items-center gap-1.5 self-center rounded-full bg-primary/10 px-2.5 font-medium text-[13px] text-primary">
<HugeiconsIcon
icon={BubbleChatTemporaryIcon}
strokeWidth={2}
className="size-3.5"
/>
<span>Temporary</span>
</div>
)}
{view.mode !== "compare" && currentProjectId && (
<nav
aria-label="Project location"
@ -2190,7 +2263,7 @@ export function ChatPage(): ReactElement {
<NativeModelChip
intent={pendingNativeModelIntent}
nativeReadsDisabled={!nativePathLeasesSupported}
onLoad={(selection) => selectModel(selection)}
onLoad={(selection) => stageOrLoad(selection)}
/>
) : null}
{loadingModel && loadToastDismissed ? (
@ -2244,7 +2317,7 @@ export function ChatPage(): ReactElement {
type="button"
onClick={toggleIncognito}
className={cn(
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[12px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
incognito
? "bg-primary/10 text-primary hover:bg-primary/15"
: "text-nav-fg hover:bg-nav-surface-hover hover:text-black dark:hover:text-white",
@ -2274,7 +2347,7 @@ export function ChatPage(): ReactElement {
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-full text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open run settings"
data-tour="chat-settings"
>
@ -2340,7 +2413,13 @@ export function ChatPage(): ReactElement {
<ChatSettingsPanel
open={settingsOpen}
onOpenChange={setSettingsOpen}
onOpenChange={(open) => {
setSettingsOpen(open);
// Closing the sheet abandons a staged (not-yet-loaded) pick: cancel its
// download and revert the staged knobs so nothing lingers as a dirty
// edit (or a background download) on the loaded model.
if (!open) abandonStaged();
}}
params={inferenceParams}
onParamsChange={setInferenceParams}
isExternalModel={isExternalModel}
@ -2366,6 +2445,47 @@ export function ChatPage(): ReactElement {
});
}
}}
onLoadPendingModel={() => {
const pending = useChatRuntimeStore.getState().pendingSelection;
if (!pending) return;
const keyAtLoad = chatContextKey;
// forceReload: the staged model isn't loaded yet, so bypass the
// same-checkpoint dedupe (and selectModel clears pendingSelection).
// keepSpeculative: honor the speculative mode set on the sidebar.
void selectModel({
...pending,
forceReload: true,
keepSpeculative: true,
throwOnError: true,
}).catch(() => {
// Recoverable failure (expired token, gated repo, OOM…): selectModel
// cleared the pick but left the edited knobs intact.
const store = useChatRuntimeStore.getState();
// A pick staged meanwhile owns the knobs now; leave it untouched.
if (store.pendingSelection) return;
// Restore (not re-stage, which would reset the knobs) only if the
// staged-load is still wanted: same chat context, sheet still open,
// page still mounted.
const stillWanted =
mountedRef.current &&
store.settingsPanelOpen &&
chatContextKeyRef.current === keyAtLoad;
if (stillWanted) {
store.setPendingSelection(pending);
} else {
// Abandoned (closed the sheet / switched chats / left chat): drop
// the orphaned staged knob edits so they don't linger as dirty
// settings over the loaded model.
store.resetModelSettingsToLoaded();
}
});
}}
stagedDownloadFraction={stagedDownload.progress?.fraction ?? null}
onCancelStagedDownload={() =>
stagedDownload.cancelDownload(
useChatRuntimeStore.getState().pendingSelection?.ggufVariant ?? null,
)
}
/>
</div>
);

View file

@ -54,18 +54,14 @@ import {
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InfoHint } from "@/components/ui/info-hint";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { cn } from "@/lib/utils";
import {
ArrowTurnBackwardIcon,
Edit03Icon,
InformationCircleIcon,
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
@ -101,7 +97,10 @@ import {
providerSupportsBuiltinCodeExecution,
providerSupportsFastMode,
} from "./provider-capabilities";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
isPendingGguf,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
import { RetrievalSettingsSection } from "@/features/rag/components/retrieval-settings-section";
import type { InferenceParams } from "./types/runtime";
@ -112,33 +111,6 @@ function canUseStorage(): boolean {
return typeof window !== "undefined";
}
export function InfoHint({ children }: { children: ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="More info"
className="inline-flex size-4 shrink-0 cursor-help items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-[#383835] dark:hover:text-[#e8e8e8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={InformationCircleIcon}
strokeWidth={1.75}
className="size-3.5"
/>
</button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={8}
className="tooltip-compact [&_span>svg]:hidden! duration-0 max-w-64"
>
{children}
</TooltipContent>
</Tooltip>
);
}
/**
* Editable numeric value display, shared by every slider value and the Context
* Length input. An <input> that looks like text (shows `displayValue ?? value`,
@ -466,6 +438,12 @@ interface ChatSettingsPanelProps {
*/
externalProviderType?: string | null;
onReloadModel?: () => void;
/** Loads the staged `pendingSelection` (deferred "Load on selection" flow). */
onLoadPendingModel?: () => void;
/** Download progress (01) for a staged GGUF being fetched, or null when idle. */
stagedDownloadFraction?: number | null;
/** Cancels the in-flight staged download (paired with abandoning the stage). */
onCancelStagedDownload?: () => void;
}
export function ChatSettingsPanel({
@ -479,6 +457,9 @@ export function ChatSettingsPanel({
onExternalProviderChange,
externalProviderType = null,
onReloadModel,
onLoadPendingModel,
stagedDownloadFraction,
onCancelStagedDownload,
}: ChatSettingsPanelProps) {
// Local models show every knob; providerCapabilities is only consulted when
// isExternalModel. Unknown providers fall back to the OpenAI-compat shape via
@ -493,9 +474,31 @@ export function ChatSettingsPanel({
const showPresencePenalty =
!isExternalModel || Boolean(providerCapabilities?.presencePenalty);
const isMobile = useIsMobile();
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const pendingSelection = useChatRuntimeStore((s) => s.pendingSelection);
const abandonStagedModel = useChatRuntimeStore((s) => s.abandonStagedModel);
const resetModelSettingsToLoaded = useChatRuntimeStore(
(s) => s.resetModelSettingsToLoaded,
);
// A staged GGUF pick (deferred load) shows the GGUF load knobs so they can be
// set before the single load.
const pendingIsGguf = isPendingGguf(pendingSelection);
// Short, human-readable name for the staged pick (HF ids carry an org prefix;
// native picks are already a display label). Drives the "staged, not loaded"
// callout so it's obvious the selection hasn't loaded yet.
const stagedLabel = (() => {
const id = pendingSelection?.id ?? "";
const slash = id.lastIndexOf("/");
const base = slash >= 0 ? id.slice(slash + 1) : id;
return base || id;
})();
const isLoadedGguf =
useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const isGguf = isLoadedGguf || pendingIsGguf;
// A staged pick is always a local GGUF, so show its Model section (and the
// Load button) even when the currently active model is external.
const hasModelContent =
!isExternalModel && (isGguf || Boolean(params.checkpoint));
pendingSelection != null ||
(!isExternalModel && (isGguf || Boolean(params.checkpoint)));
const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType);
const loadedSpeculativeType = useChatRuntimeStore(
@ -560,8 +563,25 @@ export function ChatSettingsPanel({
const setActivePreset = useChatRuntimeStore((s) => s.setActivePreset);
const settingsHydrated = useChatRuntimeStore((s) => s.settingsHydrated);
const ctxDisplayValue = customContextLength ?? ggufContextLength ?? "";
const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null;
// A staged (not-yet-loaded) GGUF carries its own header context length on
// pendingSelection, so the slider can use the staged model's real ceiling
// without reading the loaded model's `ggufContextLength`.
const stagedContextLength = pendingSelection?.contextLength ?? null;
// While staging, the sheet reflects the STAGED model, so its header context
// takes precedence over the loaded model's (which may differ or be larger).
const baseContext = pendingIsGguf ? stagedContextLength : ggufContextLength;
const baseNativeContext = pendingIsGguf
? stagedContextLength
: ggufNativeContextLength;
// Context controls render once we actually have a ceiling: for a staged GGUF,
// once its header metadata arrives (post-download); otherwise post-load.
const showContextControl = pendingIsGguf
? stagedContextLength != null
: isLoadedGguf;
const stagedDownloading =
stagedDownloadFraction != null && stagedDownloadFraction < 1;
const ctxDisplayValue = customContextLength ?? baseContext ?? "";
const ctxMaxValue = baseNativeContext ?? baseContext ?? null;
const kvDirty = kvCacheDtype !== loadedKvCacheDtype;
const ctxDirty = customContextLength !== null;
const specDirty = speculativeType !== loadedSpeculativeType;
@ -569,12 +589,6 @@ export function ChatSettingsPanel({
const tpDirty = tensorParallel !== (loadedTensorParallel ?? false);
const modelSettingsDirty =
kvDirty || ctxDirty || specDirty || specDraftDirty || tpDirty;
const loadedChatTemplateOverride = useChatRuntimeStore(
(s) => s.loadedChatTemplateOverride,
);
const setChatTemplateOverride = useChatRuntimeStore(
(s) => s.setChatTemplateOverride,
);
const [presetNameInput, setPresetNameInput] = useState(activePreset);
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState("");
@ -816,7 +830,7 @@ export function ChatSettingsPanel({
<button
type="button"
onClick={() => onOpenChange?.(false)}
className="flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[12px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-full text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close run settings"
>
<HugeiconsIcon
@ -846,8 +860,19 @@ export function ChatSettingsPanel({
{hasModelContent && (
<CollapsibleSection label="Model" defaultOpen={true} first>
<div className="flex flex-col gap-4 pt-1">
{pendingSelection && (
<Alert className="rounded-[14px] border-primary/30 bg-primary/5 px-3 py-2">
<AlertTitle className="text-[12px] font-medium">
{stagedLabel} is staged, not loaded yet
</AlertTitle>
<AlertDescription className="text-[11.5px] leading-[1.45] text-muted-foreground">
Set the options below, then choose Load model to load it.
</AlertDescription>
</Alert>
)}
{isGguf && (
<>
{showContextControl && (
<div className="space-y-3.5">
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
@ -857,14 +882,14 @@ export function ChatSettingsPanel({
value={
typeof ctxDisplayValue === "number"
? ctxDisplayValue
: (ggufContextLength ?? 0)
: (baseContext ?? 0)
}
min={128}
max={ctxMaxValue ?? undefined}
step={1}
onChange={(v) => {
setCustomContextLength(
v === (ggufContextLength ?? 0) ? null : v,
v === (baseContext ?? 0) ? null : v,
);
}}
ariaLabel="Context Length"
@ -879,14 +904,14 @@ export function ChatSettingsPanel({
Math.min(
typeof ctxDisplayValue === "number"
? ctxDisplayValue
: (ggufContextLength ?? 4096),
: (baseContext ?? 4096),
ctxMaxValue ?? 4096,
),
]}
onValueChange={([v]) => {
const snapped = Math.round(v);
setCustomContextLength(
snapped === (ggufContextLength ?? 0) ? null : snapped,
snapped === (baseContext ?? 0) ? null : snapped,
);
}}
className="panel-slider"
@ -901,6 +926,7 @@ export function ChatSettingsPanel({
</p>
)}
</div>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
@ -937,6 +963,8 @@ export function ChatSettingsPanel({
</Select>
</div>
</div>
{isGguf && (
<>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
@ -1043,6 +1071,8 @@ export function ChatSettingsPanel({
className="h-7 w-[76px] rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px] font-medium text-nav-fg outline-none focus-visible:ring-0"
/>
</div>
)}
</>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
@ -1099,8 +1129,44 @@ export function ChatSettingsPanel({
{/* Apply/Reset belongs to the model-reload settings above (context
length, KV cache, speculative decoding). Render it here, before
the Chat Template row, so it never reads as attached to Chat
Template (which is edited via its own dialog). */}
{modelSettingsDirty && (
Template (which is edited via its own dialog). When a model is
staged (deferred load), Load/Cancel takes its place: there's
nothing loaded to "apply" against yet. */}
{pendingSelection ? (
<div className="flex flex-col gap-2">
{stagedDownloading && (
<p className="text-[11px] text-muted-foreground">
Downloading{" "}
{Math.round((stagedDownloadFraction ?? 0) * 100)}%
</p>
)}
<div className="flex flex-wrap gap-1.5">
<Button
type="button"
onClick={() => onLoadPendingModel?.()}
disabled={stagedDownloading}
size="sm"
className="h-7 px-3 text-[12px] font-medium tracking-nav bg-primary/92 text-primary-foreground hover:bg-primary"
>
Load model
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
// Cancel abandons the stage; if a download is mid-flight,
// stop it too rather than leaving it running headless.
if (stagedDownloading) onCancelStagedDownload?.();
abandonStagedModel();
}}
className="h-7 px-3 text-[12px] font-medium tracking-nav text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
) : modelSettingsDirty ? (
<div className="flex flex-wrap gap-1.5">
<Button
type="button"
@ -1114,20 +1180,13 @@ export function ChatSettingsPanel({
type="button"
variant="outline"
size="sm"
onClick={() => {
setCustomContextLength(null);
setKvCacheDtype(loadedKvCacheDtype);
setSpeculativeType(loadedSpeculativeType);
setSpecDraftNMax(loadedSpecDraftNMax);
setTensorParallel(loadedTensorParallel ?? false);
setChatTemplateOverride(loadedChatTemplateOverride);
}}
onClick={() => resetModelSettingsToLoaded()}
className="h-7 px-3 text-[12px] font-medium tracking-nav text-muted-foreground"
>
Reset
</Button>
</div>
)}
) : null}
<ChatTemplateFields />
</div>
</CollapsibleSection>
@ -1509,21 +1568,21 @@ export function ChatSettingsPanel({
: 64
}
max={
isExternalModel
// A staged GGUF caps to its own context even over an active
// external model (the staged model is what will load).
!pendingIsGguf && isExternalModel
? getExternalMaxOutputTokens(
externalProviderType,
externalSelection?.modelId,
)
: isGguf && ggufContextLength
? ggufContextLength
: isGguf && baseContext
? baseContext
: 32768
}
step={64}
onChange={set("maxTokens")}
displayValue={
isGguf &&
ggufContextLength &&
params.maxTokens >= ggufContextLength
isGguf && baseContext && params.maxTokens >= baseContext
? "Max"
: undefined
}

View file

@ -84,7 +84,7 @@ export function ChatSearchDialog() {
open={isOpen}
onOpenChange={setOpen}
className="chat-search-surface rounded-3xl! top-1/2 -translate-y-1/2 w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 ring-0 sm:max-w-[635px]"
overlayClassName="bg-transparent"
overlayClassName="bg-transparent supports-backdrop-filter:backdrop-blur-none"
>
<Command className="rounded-3xl p-0" shouldFilter={false}>
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">

View file

@ -45,7 +45,7 @@ import {
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import type { ExternalProviderConfig } from "../external-providers";
import { ensureThreadRecord } from "../runtime-provider";
import { InfoHint } from "../chat-settings-sheet";
import { InfoHint } from "@/components/ui/info-hint";
import {
getStoredChatThread,
listStoredChatThreads,

View file

@ -43,12 +43,13 @@ import {
isMultimodalResponse,
} from "../types/api";
import { isExternalModelId } from "../external-providers";
import { cancelStagedModelDownload } from "@/features/hub";
import type {
ChatLoraSummary,
ChatModelSummary,
} from "../types/runtime";
type SelectedModelInput = {
export type SelectedModelInput = {
id: string;
isLora?: boolean;
ggufVariant?: string;
@ -57,7 +58,14 @@ type SelectedModelInput = {
expectedBytes?: number;
forceReload?: boolean;
nativePathToken?: string;
/** Direct local .gguf file (no HF variant / native token) still a GGUF
* source, so the staging flow treats it as one. */
isGguf?: boolean;
throwOnError?: boolean;
/** Keep the current speculative-decoding choice across the model switch
* instead of resetting it to the standing preference. Set by the deferred
* ("Load on selection") Load, where the user picked it for this model. */
keepSpeculative?: boolean;
};
const MODEL_LOAD_TOAST_CLASSNAMES = {
@ -370,8 +378,27 @@ export function useChatModelRuntime() {
typeof selection === "string" ? false : selection.forceReload ?? false;
const nativePathToken =
typeof selection === "string" ? undefined : selection.nativePathToken;
const explicitIsGguf =
typeof selection === "string" ? undefined : selection.isGguf;
const throwOnError =
typeof selection === "string" ? false : selection.throwOnError ?? false;
const keepSpeculative =
typeof selection === "string" ? false : selection.keepSpeculative ?? false;
// Picking/loading any model abandons a staged (deferred) selection.
// Before the early-returns below so even a no-op re-select clears the
// stage, and so the Load button unmounts on first click (no double-load).
const staged = useChatRuntimeStore.getState().pendingSelection;
if (staged) {
// Loading a DIFFERENT model abandons this stage, so cancel its in-flight
// download. Loading the staged pick itself keeps it (that download feeds
// this load).
const loadingStagedPick =
staged.id === modelId &&
(staged.ggufVariant ?? null) === (ggufVariant ?? null) &&
(staged.nativePathToken ?? null) === (nativePathToken ?? null);
if (!loadingStagedPick) cancelStagedModelDownload(staged);
useChatRuntimeStore.getState().setPendingSelection(null);
}
const currentVariant = useChatRuntimeStore.getState().activeGgufVariant;
if (!forceReload && (!modelId || (params.checkpoint === modelId && (ggufVariant ?? null) === (currentVariant ?? null)))) {
return;
@ -391,6 +418,7 @@ export function useChatModelRuntime() {
typeof selection === "string" ? false : selection.isDownloaded ?? false;
const model = models.find((entry) => entry.id === modelId);
const lora = loras.find((entry) => entry.id === modelId);
const isGguf = explicitIsGguf ?? model?.isGguf ?? false;
const loraIsAdapter = lora?.exportType === "lora";
const isLora =
explicitIsLora ?? model?.isLora ?? loraIsAdapter ?? false;
@ -501,7 +529,10 @@ export function useChatModelRuntime() {
// can't follow the user onto a model without an MTP head.
// spec_draft_n_max is MTP-only and always resets. The loaded
// shadow is seeded too, preventing a transient dirty Apply state.
if (currentCheckpoint && currentCheckpoint !== modelId) {
// keepSpeculative skips this for a staged Load: the user picked the
// mode for this model on the sidebar, so honor it (the backend still
// falls back at runtime if the model has no MTP head).
if (currentCheckpoint && currentCheckpoint !== modelId && !keepSpeculative) {
const persistedSpeculativeType = readPersistedSpeculativeType();
useChatRuntimeStore.setState({
speculativeType: persistedSpeculativeType,
@ -525,6 +556,7 @@ export function useChatModelRuntime() {
const effectiveMaxSeqLength = resolveLoadMaxSeqLength({
modelId,
ggufVariant,
isGguf,
customContextLength,
ggufContextLength,
currentCheckpoint,

View file

@ -0,0 +1,114 @@
// 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 { useCallback, useEffect } from "react";
import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
import { useRepoDownload } from "@/features/hub/download-manager/use-repo-download";
import type { DownloadJob } from "@/features/hub/download-manager/use-repo-download";
import { fetchGgufContextLength } from "../api/chat-api";
import {
isPendingGguf,
useChatRuntimeStore,
} from "../stores/chat-runtime-store";
/**
* Drives the deferred ("Load on selection" off) staging flow for a GGUF:
* download the file if needed (HF repo) or read it in place (native drag-drop /
* picked file), then read its header context length so the settings sheet can
* show the real context slider before the single GPU load. The staged context
* lands on `pendingSelection.contextLength` (scoped to the staged model, never
* the loaded model's `ggufContextLength`). Returns the live download job so the
* sheet can render progress / cancel. Mount once on the chat page.
*/
export function useStagedModelPreparation(): DownloadJob {
const pendingId = useChatRuntimeStore((s) => s.pendingSelection?.id ?? null);
const pendingVariant = useChatRuntimeStore(
(s) => s.pendingSelection?.ggufVariant ?? null,
);
const pendingNativeToken = useChatRuntimeStore(
(s) => s.pendingSelection?.nativePathToken ?? null,
);
// Only GGUF picks (HF variant or native file) have a header worth reading.
const pendingIsGguf = useChatRuntimeStore((s) =>
isPendingGguf(s.pendingSelection),
);
const pendingDownloaded = useChatRuntimeStore(
(s) => s.pendingSelection?.isDownloaded ?? false,
);
const pendingHasContext = useChatRuntimeStore(
(s) => s.pendingSelection?.contextLength != null,
);
const setPendingSelection = useChatRuntimeStore((s) => s.setPendingSelection);
const fetchContextMetadata = useCallback(async () => {
const current = useChatRuntimeStore.getState().pendingSelection;
if (!current?.id || !isPendingGguf(current)) return;
const { id, ggufVariant, nativePathToken } = current;
try {
const contextLength = await fetchGgufContextLength({
model_path: id,
gguf_variant: ggufVariant,
hf_token: useChatRuntimeStore.getState().hfToken || null,
nativePathToken,
});
// Apply only if the same model is still staged (the user may have switched
// picks or loaded/cancelled while the request was in flight). Native ids
// are display labels, not paths, so two files can share an id -- compare
// the path token too, or a stale response could land on the wrong pick.
const latest = useChatRuntimeStore.getState().pendingSelection;
if (
latest?.id === id &&
(latest.ggufVariant ?? null) === (ggufVariant ?? null) &&
(latest.nativePathToken ?? null) === (nativePathToken ?? null) &&
contextLength != null
) {
setPendingSelection({ ...latest, contextLength });
}
} catch {
// Leave contextLength null: the context slider stays hidden and the user
// can still load (context fills in from the load response afterwards).
}
}, [setPendingSelection]);
const job = useRepoDownload({
kind: "model",
// useRepoDownload must be called unconditionally; an idle repo id keeps it
// inert until something is staged.
repoId: pendingId ?? "__staged_idle__",
activeVariant: pendingVariant,
onComplete: () => {
void fetchContextMetadata();
},
});
// job.requestStartDownload's identity changes per render; hold it in a ref so
// the staging effect re-runs only when the staged model itself changes.
const startDownloadRef = useLatestRef(job.requestStartDownload);
const fetchMetadataRef = useLatestRef(fetchContextMetadata);
useEffect(() => {
if (!pendingId || !pendingIsGguf || pendingHasContext) return;
// Native files and already-downloaded HF files are local: read the header
// now. Otherwise download first; onComplete then reads it.
if (pendingNativeToken || pendingDownloaded) {
void fetchMetadataRef.current();
} else {
const expectedBytes =
useChatRuntimeStore.getState().pendingSelection?.expectedBytes ?? 0;
void startDownloadRef.current(pendingVariant, expectedBytes);
}
}, [
pendingId,
pendingVariant,
pendingNativeToken,
pendingIsGguf,
pendingDownloaded,
pendingHasContext,
startDownloadRef,
fetchMetadataRef,
]);
return job;
}

View file

@ -297,6 +297,7 @@ export function mergeBackendRecommendedInference({
export function resolveLoadMaxSeqLength({
modelId,
ggufVariant,
isGguf,
customContextLength,
ggufContextLength,
currentCheckpoint,
@ -306,6 +307,7 @@ export function resolveLoadMaxSeqLength({
}: {
modelId: string;
ggufVariant?: string | null;
isGguf?: boolean | null;
customContextLength: number | null;
ggufContextLength: number | null;
currentCheckpoint: string;
@ -314,7 +316,7 @@ export function resolveLoadMaxSeqLength({
presetSource: ChatPresetSource;
}): number {
const isDirectGgufFile = modelId.toLowerCase().endsWith(".gguf");
const isGgufLoad = ggufVariant != null || isDirectGgufFile;
const isGgufLoad = isGguf === true || ggufVariant != null || isDirectGgufFile;
const isReloadingCurrentGguf =
isGgufLoad &&
currentCheckpoint === modelId &&

View file

@ -15,7 +15,6 @@ import { Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
BookmarkIcon,
CheckIcon,
DownloadIcon,
GripVerticalIcon,
LayoutListIcon,
@ -26,6 +25,7 @@ import {
UploadIcon,
XIcon,
} from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import {
type ReactElement,
useCallback,
@ -1276,7 +1276,7 @@ function PromptCard({
<XIcon className="size-3.5 mr-1" />Cancel
</Button>
<Button size="sm" onClick={handleSave}>
<CheckIcon className="size-3.5 mr-1" />Save
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3.5 mr-1" />Save
</Button>
</div>
</div>
@ -1387,7 +1387,7 @@ function NewPromptForm({ onClose, onRefresh }: { onClose: () => void; onRefresh:
<XIcon className="size-3.5 mr-1" />Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!text.trim()}>
<CheckIcon className="size-3.5 mr-1" />Save Prompt
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3.5 mr-1" />Save Prompt
</Button>
</div>
</div>
@ -1486,7 +1486,7 @@ function PromptListCard({
onClick={handleSave}
disabled={items.filter((t) => t.trim()).length === 0}
>
<CheckIcon className="size-3.5 mr-1" />Save List
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3.5 mr-1" />Save List
</Button>
</div>
</div>
@ -1637,7 +1637,7 @@ function NewPromptListForm({ onClose, onRefresh }: { onClose: () => void; onRefr
onClick={handleSave}
disabled={items.filter((t) => t.trim()).length === 0}
>
<CheckIcon className="size-3.5 mr-1" />Save Prompt List
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-3.5 mr-1" />Save Prompt List
</Button>
</div>
</div>

View file

@ -3,6 +3,7 @@
import { toast } from "@/lib/toast";
import { create } from "zustand";
import { cancelStagedModelDownload } from "@/features/hub";
import {
type ChatPresetSource,
type Preset,
@ -35,6 +36,7 @@ export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_CONFIRM_TOOL_CALLS_KEY = "unsloth_chat_confirm_tool_calls";
export const CHAT_LOAD_ON_SELECTION_KEY = "unsloth_chat_load_on_selection";
export const CHAT_BYPASS_PERMISSIONS_KEY = "unsloth_chat_bypass_permissions";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
@ -86,6 +88,7 @@ function saveRagSource(value: RagSource): void {
try {
window.localStorage.setItem(CHAT_RAG_SOURCE_KEY, JSON.stringify(value));
} catch {
// Ignore storage failures; the default RAG source still works for this session.
}
}
@ -408,6 +411,42 @@ function notifyHfTokenChanged(value: string): void {
}
}
/** A local model staged for a deferred load (see `pendingSelection`). Shape is
* a subset of the load hook's `SelectedModelInput`, structurally assignable. */
export type PendingModelSelection = {
id: string;
isLora?: boolean;
ggufVariant?: string;
isDownloaded?: boolean;
expectedBytes?: number;
/** Native (drag-drop / picked-from-disk) GGUF: the path token used to read
* the header and to load. Absent for HF-repo models. */
nativePathToken?: string;
/** Direct local .gguf file (custom folder / LM Studio): a GGUF source even
* though it carries neither an HF variant nor a native path token. */
isGguf?: boolean;
/** Native context length read from the GGUF header once the file is local.
* Scoped here (not the shared `ggufContextLength`) so a staged model's
* metadata never pollutes the currently-loaded model's context display. */
contextLength?: number | null;
};
/** A pick is a GGUF (HF variant, native file, or a direct local .gguf) and so
* has pre-load options worth staging. Works on a selection or a staged pick. */
export function hasGgufSource(x: {
ggufVariant?: string;
nativePathToken?: string;
isGguf?: boolean;
}): boolean {
return (
x.ggufVariant != null || x.nativePathToken != null || x.isGguf === true
);
}
export function isPendingGguf(pending: PendingModelSelection | null): boolean {
return pending != null && hasGgufSource(pending);
}
type ChatRuntimeStore = {
settingsHydrated: boolean;
params: InferenceParams;
@ -538,6 +577,13 @@ type ChatRuntimeStore = {
tensorParallel: boolean;
/** Backend-reported tensor-parallel state; null until first hydrated. */
loadedTensorParallel: boolean | null;
/** Persisted: when false, picking a local model stages it as
* `pendingSelection` (and opens settings) instead of loading immediately,
* so load settings can be set before the single load. */
loadOnSelection: boolean;
/** A local model picked while `loadOnSelection` is off: staged, not loaded.
* The settings sheet shows its load knobs and a Load button. */
pendingSelection: PendingModelSelection | null;
loadedIsMultimodal: boolean;
/** Active model is a block-diffusion model (DiffusionGemma): drives the
* denoising-canvas artifact auto-render. */
@ -560,6 +606,7 @@ type ChatRuntimeStore = {
*/
incognito: boolean;
settingsPanelOpen: boolean;
editingMessageId: string | null;
pendingAudioBase64: string | null;
pendingAudioName: string | null;
pendingImageEditReference: PendingImageEditReference | null;
@ -593,6 +640,7 @@ type ChatRuntimeStore = {
setActiveProjectId: (projectId: string | null) => void;
setIncognito: (incognito: boolean) => void;
setSettingsPanelOpen: (open: boolean) => void;
setEditingMessageId: (id: string | null) => void;
clearCheckpoint: () => void;
setReasoningEnabled: (
enabled: boolean,
@ -638,7 +686,20 @@ type ChatRuntimeStore = {
setKvCacheDtype: (dtype: string | null) => void;
setSpeculativeType: (type: string | null) => void;
setSpecDraftNMax: (value: number | null) => void;
/** Revert the editable load knobs to the loaded model's baseline (or defaults
* when nothing is loaded). Used by the settings-sheet Reset button and to
* start each deferred-staging session clean so one staged pick's settings
* don't leak onto the next. */
resetModelSettingsToLoaded: () => void;
setTensorParallel: (value: boolean) => void;
setLoadOnSelection: (value: boolean) => void;
setPendingSelection: (selection: PendingModelSelection | null) => void;
/** Stage a pick for a deferred load: revert knobs to the loaded baseline,
* record the selection, and open the settings sheet. */
stageModel: (selection: PendingModelSelection) => void;
/** Abandon a staged pick without loading: revert the knobs to the loaded
* baseline and clear the pending selection. */
abandonStagedModel: () => void;
setCustomContextLength: (v: number | null) => void;
setChatTemplateOverride: (template: string | null) => void;
setPendingAudio: (base64: string, name: string) => void;
@ -839,6 +900,23 @@ function setScalarSettingVersion<K extends ScalarSettingKey>(
saveSettingsPatch({ [key]: value });
}
/** The "revert to the loaded model" baseline for the editable load knobs.
* Shared by resetModelSettingsToLoaded (full revert) and stageModel (which
* overrides speculative to start a fresh pick from the standing default). */
function loadedBaselineSettings(s: ChatRuntimeStore) {
const hasLoadedModel = Boolean(s.params.checkpoint);
return {
customContextLength: null,
kvCacheDtype: s.loadedKvCacheDtype,
tensorParallel: s.loadedTensorParallel ?? false,
speculativeType: hasLoadedModel
? s.loadedSpeculativeType
: readPersistedSpeculativeType(),
specDraftNMax: hasLoadedModel ? s.loadedSpecDraftNMax : null,
chatTemplateOverride: s.loadedChatTemplateOverride,
};
}
export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
settingsHydrated: false,
// Hydrate the last external checkpoint so the external picker survives a
@ -924,6 +1002,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
loadedSpecDraftNMax: null,
tensorParallel: false,
loadedTensorParallel: null,
loadOnSelection: loadBool(CHAT_LOAD_ON_SELECTION_KEY, true),
pendingSelection: null,
loadedIsMultimodal: false,
loadedIsDiffusion: false,
customContextLength: null,
@ -934,6 +1014,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
activeProjectId: null,
incognito: false,
settingsPanelOpen: false,
editingMessageId: null,
pendingAudioBase64: null,
pendingAudioName: null,
pendingImageEditReference: null,
@ -1061,6 +1142,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
// Clear stale per-turn usage on model change; the relaxed external-provider
// render gate would otherwise show old counters until the next completion.
const checkpointChanged = state.params.checkpoint !== modelId;
const pendingToClear =
checkpointChanged && state.params.checkpoint ? state.pendingSelection : null;
if (pendingToClear) {
cancelStagedModelDownload(pendingToClear);
}
// Clamp maxTokens to the new model's cap when switching into an external
// model so a value carried over from a local session doesn't exceed the
// slider's max.
@ -1088,6 +1174,14 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
},
activeGgufVariant: ggufVariant ?? null,
...(checkpointChanged ? { contextUsage: null } : {}),
// Switching away from a loaded model (e.g. picking an external provider)
// abandons any staged pick, so its Load button and edited knobs don't
// linger over the newly active model. Same revert as abandonStagedModel.
// Guarded on a non-empty current checkpoint: an establishing set from a
// background status sync (empty -> active) must not wipe a fresh stage.
...(pendingToClear
? { ...loadedBaselineSettings(state), pendingSelection: null }
: {}),
};
}),
setActiveThreadId: (activeThreadId) =>
@ -1095,11 +1189,13 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
setActiveProjectId: (activeProjectId) => set({ activeProjectId }),
setIncognito: (incognito) => set({ incognito }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
setEditingMessageId: (id) => set({ editingMessageId: id }),
clearCheckpoint: () => {
// Mirror setCheckpoint's persistence: dropping the checkpoint must also
// clear any stored external selection so the next refresh doesn't snap
// back to a model the user intentionally cleared.
saveLastExternalCheckpoint(null);
cancelStagedModelDownload(get().pendingSelection);
return set((state) => ({
params: {
...state.params,
@ -1107,6 +1203,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
},
activeGgufVariant: null,
activeNativePathToken: null,
pendingSelection: null,
ggufContextLength: null,
ggufMaxContextLength: null,
ggufNativeContextLength: null,
@ -1339,6 +1436,43 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
setSpeculativeType: (speculativeType) => set({ speculativeType }),
setSpecDraftNMax: (specDraftNMax) => set({ specDraftNMax }),
setTensorParallel: (tensorParallel) => set({ tensorParallel }),
resetModelSettingsToLoaded: () => set((s) => loadedBaselineSettings(s)),
setLoadOnSelection: (loadOnSelection) => {
saveBool(CHAT_LOAD_ON_SELECTION_KEY, loadOnSelection);
set({ loadOnSelection });
},
setPendingSelection: (pendingSelection) => set({ pendingSelection }),
stageModel: (selection) =>
set((s) => {
if (
s.pendingSelection &&
(s.pendingSelection.id !== selection.id ||
(s.pendingSelection.ggufVariant ?? null) !==
(selection.ggufVariant ?? null))
) {
cancelStagedModelDownload(s.pendingSelection);
}
return {
...loadedBaselineSettings(s),
pendingSelection: selection,
settingsPanelOpen: true,
// Speculative starts from the standing default, not the loaded model's
// mode, so a fresh pick doesn't inherit (and then carry, via the staged
// Load's keepSpeculative) a forced MTP mode onto a model that may lack it.
speculativeType: readPersistedSpeculativeType(),
specDraftNMax: null,
};
}),
abandonStagedModel: () => {
const { pendingSelection } = get();
if (!pendingSelection) return;
// Cancel the staged pick's in-flight download so it doesn't keep running
// after the staging UI is gone. Centralized here so every abandon path
// (sheet close, thread switch, route exit, new chat) cancels it, including
// root-level callers that have no access to the useRepoDownload hook.
cancelStagedModelDownload(pendingSelection);
set((s) => ({ ...loadedBaselineSettings(s), pendingSelection: null }));
},
setCustomContextLength: (customContextLength) => set({ customContextLength }),
setChatTemplateOverride: (chatTemplateOverride) =>
set({ chatTemplateOverride }),

View file

@ -72,6 +72,8 @@ export interface ValidateModelResponse {
is_lora?: boolean;
is_vision?: boolean;
requires_trust_remote_code?: boolean;
/** Native context length from the local GGUF header; null until downloaded. */
context_length?: number | null;
}
export interface GgufVariantDetail {
@ -193,6 +195,38 @@ export interface InferenceStatusResponse {
spec_fallback_reason?: string | null;
}
export interface ApiMonitorEntry {
id: string;
endpoint: string;
method: string;
model: string;
prompt?: string;
reply?: string;
prompt_preview: string;
reply_preview: string;
prompt_truncated: boolean;
reply_truncated: boolean;
status: "running" | "completed" | "cancelled" | "error";
started_at: number;
updated_at: number;
finished_at?: number | null;
duration_ms?: number | null;
context_length?: number | null;
context_usage?: number | null;
prompt_tokens?: number | null;
completion_tokens?: number | null;
total_tokens?: number | null;
error?: string | null;
}
export interface ApiMonitorResponse {
status: "idle" | "ready" | "generating";
active_model?: string | null;
context_length?: number | null;
active_requests: number;
entries: ApiMonitorEntry[];
}
export interface AudioGenerationResponse {
id: string;
object: string;

View file

@ -0,0 +1,155 @@
import type { ExportedMessageRepository, ThreadMessage } from "@assistant-ui/react";
import { saveChatMessage } from "../api/chat-api";
type ThreadImportExport = {
export: () => ExportedMessageRepository;
import: (data: ExportedMessageRepository) => void;
};
type ContentPart = { type: "text" | "reasoning" | "tool"; text: string };
/**
* Extracts only the editable text and reasoning from a message,
* ignoring structured parts like tool calls that cannot be edited as plain text.
*/
export function extractTaggedText(content: any): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return "";
const open = "\u003C"; // <
const close = "\u003E"; // >
return content
.map((part: any) => {
if (typeof part === 'string') return part;
if (!part) return "";
// Only extract text from 'text' or 'reasoning' parts.
// Tool calls/responses are ignored here so they aren't accidentally
// deleted or corrupted by the user in the textarea.
const text = part.text || part.content || "";
if (!text) return "";
switch (part.type) {
case 'reasoning':
// Trim the text first so we don't accumulate newlines
// around the tags on every save.
return `${open}THINK${close}\n${text.trim()}\n${open}/THINK${close}`;
case 'text':
default:
return text;
}
})
.filter(Boolean)
.join('\n\n');
}
function parseTaggedTextToContent(text: string): ContentPart[] {
const parts: ContentPart[] = [];
const tagRegex = /(<\/?(THINK|TOOL)>)/g;
let lastIndex = 0;
let match;
let currentType: ContentPart["type"] = "text";
while ((match = tagRegex.exec(text)) !== null) {
const fullTag = match[0];
const tagName = match[2];
const index = match.index;
if (index > lastIndex) {
// Trim the extracted content to remove any leading/trailing
// newlines created by the tag wrapping process.
const content = text.substring(lastIndex, index).trim();
if (content) parts.push({ type: currentType, text: content });
}
currentType = fullTag.startsWith("</") ? "text" : (tagName === "THINK" ? "reasoning" : "tool");
lastIndex = index + fullTag.length;
}
if (lastIndex < text.length) {
const remainingText = text.substring(lastIndex).trim();
if (remainingText) parts.push({ type: currentType, text: remainingText });
}
return parts;
}
export async function updateThreadMessage(args: {
thread: ThreadImportExport;
messageId: string;
remoteId: string | undefined;
newText: string;
isIncognito: boolean; // <--- ADD THIS
}) {
const { thread, messageId, remoteId, newText, isIncognito } = args;
const parsedEditableContent = parseTaggedTextToContent(newText);
const currentExport = thread.export();
const targetMessageEntry = currentExport.messages.find(m => m.message.id === messageId);
if (!targetMessageEntry) {
throw new Error(`Message with ID ${messageId} not found in thread.`);
}
const { parentId: originalParentId } = targetMessageEntry;
const { createdAt: originalCreatedAt } = targetMessageEntry.message;
const updatedMessages = currentExport.messages.map((m) => {
if (m.message.id !== messageId) return m;
const originalContent = m.message.content;
let finalContent: any[] = [];
if (Array.isArray(originalContent)) {
const firstEditableIndex = originalContent.findIndex((part: any) =>
part.type === 'text' || part.type === 'reasoning'
);
if (firstEditableIndex === -1) {
const nonEditableParts = originalContent.filter((part: any) =>
part.type !== 'text' && part.type !== 'reasoning'
);
finalContent = [...parsedEditableContent, ...nonEditableParts];
} else {
const before = originalContent.slice(0, firstEditableIndex);
const after = originalContent.slice(firstEditableIndex + 1).filter((part: any) =>
part.type !== 'text' && part.type !== 'reasoning'
);
finalContent = [...before, ...parsedEditableContent, ...after];
}
} else {
finalContent = parsedEditableContent;
}
return {
...m,
message: {
...m.message,
content: finalContent,
},
};
}) as typeof currentExport.messages;
const originalExport = currentExport;
thread.import({ ...currentExport, messages: updatedMessages });
// If it's NOT incognito, we attempt to save to the DB regardless of the ID.
if (remoteId && !isIncognito) {
try {
await saveChatMessage({
id: messageId,
threadId: remoteId,
parentId: originalParentId,
role: "assistant",
content: (updatedMessages.find(m => m.message.id === messageId)?.message.content) || [],
createdAt: originalCreatedAt ? Number(originalCreatedAt) : Date.now(),
});
} catch (e) {
thread.import(originalExport);
console.error("Backend sync failed for message update. Rolling back UI.", e);
throw e;
}
}
return (updatedMessages.find(m => m.message.id === messageId)?.message.content) || [];
}

View file

@ -41,6 +41,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import { RecentTrainingsSection } from "@/features/studio/recent-trainings-section";
import type { ReactElement } from "react";
import { useEffect, useState } from "react";
import {
@ -528,6 +529,8 @@ export function DataRecipesPage(): ReactElement {
))}
</div>
)}
<RecentTrainingsSection />
</main>
<Dialog open={learningDialogOpen} onOpenChange={setLearningDialogOpen}>

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { SectionCard } from "@/components/section-card";
import { RecentTrainingsSection } from "@/features/studio/recent-trainings-section";
import { Button } from "@/components/ui/button";
import {
Combobox,
@ -1129,6 +1130,8 @@ export function ExportPage() {
</>
)}
</SectionCard>
<RecentTrainingsSection />
</main>
<ExportDialog

View file

@ -0,0 +1,149 @@
// 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 { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
export const CARD_GAP_PX = 16;
const CAROUSEL_TOP_PADDING_PX = 8;
export function CarouselArrow({
side,
visible,
centerPx,
onClick,
}: {
side: "left" | "right";
visible: boolean;
centerPx: number;
onClick: () => void;
}) {
return (
<button
type="button"
aria-label={side === "left" ? "Scroll left" : "Scroll right"}
aria-hidden={!visible}
tabIndex={visible ? 0 : -1}
onClick={onClick}
style={{ top: centerPx }}
className={cn(
"hub-carousel-arrow absolute z-10 inline-flex size-9 -translate-y-1/2 items-center justify-center rounded-full",
side === "left" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2",
visible
? "opacity-20 group-hover/carousel:opacity-100"
: "pointer-events-none opacity-0",
)}
>
<HugeiconsIcon
icon={side === "left" ? ArrowLeft01Icon : ArrowRight01Icon}
strokeWidth={2}
className="size-4"
/>
</button>
);
}
export function CardCarousel<T>({
items,
getKey,
renderItem,
itemWidth,
itemHeight,
ariaLabel,
}: {
items: T[];
getKey: (item: T) => string;
renderItem: (item: T) => ReactNode;
itemWidth: number;
itemHeight: number;
ariaLabel: string;
}) {
const scrollerRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
const stepPx = itemWidth + CARD_GAP_PX;
const arrowCenterPx = CAROUSEL_TOP_PADDING_PX + itemHeight / 2;
const updateArrows = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
setCanLeft(el.scrollLeft > 1);
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
updateArrows();
const observer = new ResizeObserver(updateArrows);
observer.observe(el);
return () => observer.disconnect();
}, [updateArrows]);
useEffect(() => {
updateArrows();
}, [updateArrows, items]);
const scrollByCards = useCallback(
(direction: 1 | -1) => {
scrollerRef.current?.scrollBy({
left: direction * stepPx,
behavior: "smooth",
});
},
[stepPx],
);
return (
<div className="relative">
<div
ref={scrollerRef}
onScroll={updateArrows}
aria-label={ariaLabel}
className="hub-carousel flex snap-x gap-4 overflow-x-auto pb-4 pt-2"
>
{items.map((item) => (
<div
key={getKey(item)}
className="shrink-0 snap-start"
style={{ width: itemWidth, height: itemHeight }}
>
{renderItem(item)}
</div>
))}
</div>
<div
aria-hidden="true"
data-visible={canLeft || undefined}
className="hub-carousel-fade hub-carousel-fade-left"
style={{ top: CAROUSEL_TOP_PADDING_PX, height: itemHeight }}
/>
<div
aria-hidden="true"
data-visible={canRight || undefined}
className="hub-carousel-fade hub-carousel-fade-right"
style={{ top: CAROUSEL_TOP_PADDING_PX, height: itemHeight }}
/>
<CarouselArrow
side="left"
visible={canLeft}
centerPx={arrowCenterPx}
onClick={() => scrollByCards(-1)}
/>
<CarouselArrow
side="right"
visible={canRight}
centerPx={arrowCenterPx}
onClick={() => scrollByCards(1)}
/>
</div>
);
}

View file

@ -5,7 +5,7 @@ import {
CloudOffIcon,
CubeIcon,
FilterIcon,
RefreshIcon,
Refresh01Icon,
WifiDisconnected02Icon,
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
@ -50,7 +50,7 @@ export function NetworkErrorState({
<button
type="button"
onClick={onSwitchDevice}
className="inline-flex h-8 items-center gap-1.5 rounded-[10px] bg-foreground/[0.06] px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]"
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-foreground/[0.06] px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]"
>
On Device
</button>
@ -58,10 +58,10 @@ export function NetworkErrorState({
<button
type="button"
onClick={onRetry}
className="inline-flex h-8 items-center gap-1.5 rounded-[10px] bg-transparent px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] dark:hover:bg-white/[0.1]"
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-transparent px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] dark:hover:bg-white/[0.05]"
>
<HugeiconsIcon
icon={RefreshIcon}
icon={Refresh01Icon}
strokeWidth={1.75}
className="size-3.5"
/>
@ -104,7 +104,7 @@ export function DiscoverFetchMoreState({
<button
type="button"
onClick={onClearFilters}
className="inline-flex h-8 items-center gap-1.5 rounded-[10px] bg-foreground/[0.06] px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]"
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-foreground/[0.06] px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]"
>
Clear filters
</button>
@ -113,10 +113,10 @@ export function DiscoverFetchMoreState({
type="button"
onClick={onFetchMore}
disabled={isLoadingMore}
className="inline-flex h-8 items-center gap-1.5 rounded-[10px] bg-transparent px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/[0.1]"
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-transparent px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/[0.05]"
>
<HugeiconsIcon
icon={RefreshIcon}
icon={Refresh01Icon}
strokeWidth={1.75}
className="size-3.5"
/>
@ -128,35 +128,30 @@ export function DiscoverFetchMoreState({
}
export function DiscoverFetchMoreFooter({
scannedCount,
manualFetchAvailable,
hasActiveFilters,
isLoadingMore,
onFetchMore,
}: {
scannedCount: number;
manualFetchAvailable: boolean;
hasActiveFilters: boolean;
isLoadingMore: boolean;
onFetchMore: () => void;
}) {
return (
<div className="relative z-10 flex flex-col items-center gap-2 bg-card px-4 py-4 text-center">
<p className="text-[11.5px] leading-4 text-muted-foreground">
{hasActiveFilters
? "Some results may be hidden by your filters."
: manualFetchAvailable
? `Scanned ${scannedCount.toLocaleString()} results. Load more to continue.`
: "More results are available."}
</p>
<div className="relative z-10 flex flex-col items-center gap-2 rounded-[16px] bg-card px-4 py-4 text-center">
{/* Only warn about hidden results when a filter is actually narrowing them. */}
{hasActiveFilters && (
<p className="text-[11.5px] leading-4 text-muted-foreground">
Some results may be hidden by your filters.
</p>
)}
<button
type="button"
onClick={onFetchMore}
disabled={isLoadingMore}
className="inline-flex h-8 items-center gap-1.5 rounded-[10px] bg-foreground/[0.06] px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/[0.06] dark:hover:bg-white/[0.1]"
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-foreground/[0.06] px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/[0.06] dark:hover:bg-white/[0.1]"
>
<HugeiconsIcon
icon={RefreshIcon}
icon={Refresh01Icon}
strokeWidth={1.75}
className="size-3.5"
/>
@ -191,9 +186,9 @@ export function InventoryErrorState({
<button
type="button"
onClick={onRetry}
className="inline-flex h-8 items-center gap-1.5 rounded-[10px] bg-transparent px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] dark:hover:bg-white/[0.1]"
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-transparent px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] dark:hover:bg-white/[0.05]"
>
<HugeiconsIcon icon={RefreshIcon} strokeWidth={1.75} className="size-3.5" />
<HugeiconsIcon icon={Refresh01Icon} strokeWidth={1.75} className="size-3.5" />
Try again
</button>
</div>

View file

@ -160,7 +160,7 @@ export function DatasetDownloadSection({
)}
</div>
</div>
{/* Train CTA hidden until Hub->train picker ships; divider pairs with it. */}
{/* Train CTA hidden until Hubtrain picker ships; divider pairs with it. */}
{(!isDownloaded || downloading || HUB_POST_DOWNLOAD_ACTIONS_VISIBLE) && (
<CardDivider />
)}

View file

@ -8,7 +8,8 @@ import { HugeiconsIcon } from "@hugeicons/react";
/**
* Inspector action-button affordance during a download: spinner that cross-fades
* to a cancel glyph on `.hub-action-btn` hover, in the same 16x16 slot so the
* percentage label never shifts. The swap is pure CSS; the component only carries
* percentage label never shifts. The swap is pure CSS
* (`.hub-action-btn:hover .hub-cta-indicator-*`); the component only carries
* the marker classes.
*/
export function DownloadCancelIndicator() {

View file

@ -34,8 +34,9 @@ import {
} from "./use-download-card-state";
/**
* Shared shell for every download surface (safetensors, GGUF, dataset): card frame,
* progress bar, transport-conflict dialog, plus card-specific `dialogs` and children.
* Shared shell for every download surface (safetensors, GGUF, dataset): card
* frame, progress bar, transport-conflict dialog, plus card-specific `dialogs`
* and children.
*/
export function DownloadCard({
job,
@ -94,7 +95,7 @@ export function CardDeleteButton({
e.stopPropagation();
onClick();
}}
className="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-[8px] text-muted-foreground opacity-0 transition-[opacity,background-color,color] duration-150 hover:bg-rose-500/10 hover:text-rose-600 focus-visible:opacity-100 group-hover/dl:opacity-100 dark:hover:bg-rose-500/15 dark:hover:text-rose-400"
className="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground opacity-0 transition-[opacity,background-color,color] duration-150 hover:bg-rose-500/10 hover:text-rose-600 focus-visible:opacity-100 group-hover/dl:opacity-100 dark:hover:bg-rose-500/15 dark:hover:text-rose-400"
>
<HugeiconsIcon
icon={Delete02Icon}
@ -110,10 +111,7 @@ export function CardDeleteButton({
);
}
/**
* Download / Cancel / Resume button for the safetensors and dataset cards
* (the GGUF card folds Run/Chat into its own bespoke CTA).
*/
/** Download / Cancel / Resume button for the safetensors and dataset cards. */
export function DownloadActionButton({
downloading,
cancelling,

View file

@ -39,11 +39,11 @@ export function ExternalLinkConfirmDialog() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="size-12">
<AlertDialogMedia>
<HugeiconsIcon
icon={LinkSquare02Icon}
strokeWidth={1.75}
className="size-5 text-muted-foreground"
className="text-muted-foreground"
/>
</AlertDialogMedia>
<AlertDialogTitle>Open external link</AlertDialogTitle>

View file

@ -26,7 +26,6 @@ import {
normalizeGgufVariantIdentity,
} from "../lib/model-identity";
import { cn } from "@/lib/utils";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { useHfTokenStore } from "../stores/hf-token-store";
import {
Delete02Icon,
@ -35,6 +34,7 @@ import {
PencilEdit02Icon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
memo,
@ -588,7 +588,7 @@ export function GgufDownloadCard({
const variantListUnavailable = !sortedVariants || sortedVariants.length === 0;
const showVariantLoadingState = loading && variantListUnavailable;
// Keep showing download progress even when the variant list is unavailable, so a
// Keep showing download progress while the variant list is unavailable, so a
// remount never hides an in-flight download behind the variant status card.
if (progress && variantListUnavailable) {
return (
@ -666,7 +666,7 @@ export function GgufDownloadCard({
e.preventDefault();
setOpen((o) => !o);
}}
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-full px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.1] dark:data-[state=open]:bg-white/[0.06]"
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-full px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.04] dark:data-[state=open]:bg-white/[0.06]"
>
{selected ? (
<QuantBadge
@ -714,7 +714,6 @@ export function GgufDownloadCard({
)}
<HugeiconsIcon
icon={ChevronDownStandardIcon}
strokeWidth={1.25}
className="ml-0.5 size-3.5 shrink-0"
/>
</span>
@ -723,7 +722,7 @@ export function GgufDownloadCard({
<PopoverContent
align="start"
side="bottom"
sideOffset={0}
sideOffset={8}
avoidCollisions={false}
className="hub-menu-instant menu-soft-surface w-[var(--radix-popover-trigger-width)] min-w-[200px] gap-0 overflow-hidden p-0 py-2 ring-0"
>
@ -831,11 +830,7 @@ export function GgufDownloadCard({
</>
) : selected?.downloaded ? (
<>
<HugeiconsIcon
icon={PlayIcon}
strokeWidth={1.75}
className="translate-x-px"
/>
<HugeiconsIcon icon={PlayIcon} strokeWidth={1.75} />
Run
</>
) : (

View file

@ -0,0 +1,81 @@
// 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 { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@/lib/utils";
import { type ComponentProps, useEffect, useRef, useState } from "react";
import { ModelInspector } from "./model-inspector";
type InspectorProps = ComponentProps<typeof ModelInspector>;
export function HubDetailView({
onBack,
compact = false,
...inspectorProps
}: InspectorProps & { onBack: () => void; compact?: boolean }) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [scrolled, setScrolled] = useState(false);
// Split pane is narrower than the full-page overlay; tighter measure reads better.
const measure = compact
? "mx-auto w-full max-w-[860px] px-5 sm:px-5"
: "mx-auto w-full max-w-[1100px] px-5 sm:px-8";
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const next = el.scrollTop > 0;
setScrolled((current) => (current === next ? current : next));
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Same top scroll fade as the left column. The sticky back-bar, when
shown, sits above and hides it. */}
<div
aria-hidden="true"
data-scrolled={scrolled || undefined}
className="hub-scroll-fade pointer-events-none absolute inset-x-0 top-0 z-10 h-7"
/>
<div
ref={scrollRef}
data-hub-scroll="true"
// Right margin nudges the scrollbar in from the pane's edge.
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto [overflow-anchor:none] mr-2 [scrollbar-gutter:stable] [scrollbar-width:thin]"
>
<div
className={cn(
"hub-detail-bar sticky top-0 z-20",
// In split view on large screens the list sits alongside, so "Back
// to Hub" is redundant; keep it only for the overlay where it's hidden.
compact && "lg:hidden",
)}
data-scrolled={scrolled || undefined}
>
<div className={`${measure} py-3`}>
<button
type="button"
onClick={onBack}
className="-ml-1.5 inline-flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-full pl-1.5 pr-2.5 text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-foreground/[0.05] hover:text-foreground dark:hover:bg-white/[0.06]"
>
<HugeiconsIcon
icon={ArrowLeft01Icon}
strokeWidth={1.75}
className="size-3.5"
/>
Back to Hub
</button>
</div>
</div>
<div className={cn(measure, "pb-20", compact && "lg:pt-4")}>
<ModelInspector {...inspectorProps} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,40 @@
// 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 { memo } from "react";
import { HUB_SECTION_TITLE, type HubSection } from "../lib/channels";
import type { DiscoverRow } from "../types";
import { HubSectionRow } from "./hub-section-row";
export interface HubFeedSectionData {
rows: DiscoverRow[];
isLoading: boolean;
}
export const HubFeed = memo(function HubFeed({
trending,
deviceType,
isDataset,
onSelect,
onOpenChannel,
}: {
trending: HubFeedSectionData;
deviceType: string | null;
isDataset: boolean;
onSelect: (id: string) => void;
onOpenChannel: (section: HubSection) => void;
}) {
return (
<div className="flex flex-col gap-10">
<HubSectionRow
title={HUB_SECTION_TITLE.trending}
rows={trending.rows}
isLoading={trending.isLoading}
onSelect={onSelect}
onOpenList={() => onOpenChannel("trending")}
deviceType={deviceType}
isDataset={isDataset}
/>
</div>
);
});

View file

@ -6,9 +6,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type KeyboardEvent,
@ -167,18 +167,13 @@ export function HubOptionMenu<T extends string>({
}
}}
>
<span className="flex min-w-0 items-center gap-2">
{triggerContent ?? (
<span className="min-w-0 truncate">
{selected?.triggerLabel ?? selected?.label ?? value}
</span>
)}
<span className="min-w-0 flex-1 truncate text-left">
{triggerContent ?? selected?.triggerLabel ?? selected?.label ?? value}
</span>
{showChevron && (
<HugeiconsIcon
icon={ChevronDownStandardIcon}
strokeWidth={1.5}
className="size-3 shrink-0 text-muted-foreground"
className="size-3.5 shrink-0 text-muted-foreground"
/>
)}
</button>
@ -186,11 +181,11 @@ export function HubOptionMenu<T extends string>({
<PopoverContent
align={align}
side="bottom"
sideOffset={0}
sideOffset={8}
collisionPadding={12}
onCloseAutoFocus={(event) => event.preventDefault()}
className={cn(
"hub-menu-instant menu-soft-surface w-max min-w-[var(--radix-popover-trigger-width)] max-w-[min(var(--radix-popover-content-available-width),calc(100vw-1rem))] rounded-[21px] px-[9px] py-2 ring-0",
"hub-menu-instant menu-soft-surface w-max min-w-[var(--radix-popover-trigger-width)] max-w-[min(var(--radix-popover-content-available-width),calc(100vw-1rem))] rounded-[14px] p-1 ring-0",
contentClassName,
)}
>
@ -219,14 +214,14 @@ export function HubOptionMenu<T extends string>({
}}
onPointerEnter={() => activateIndex(index)}
className={cn(
"relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2.5 rounded-[12px] py-2 px-3 text-left text-sm leading-snug outline-none transition-colors",
"relative flex w-full min-w-0 cursor-pointer select-none items-center rounded-[12px] py-2 pr-8 pl-3 text-left text-sm leading-snug outline-none transition-colors",
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2.5 overflow-hidden whitespace-normal break-words">
{option.label}
</span>
{selectedOption && (
<span className="pointer-events-none flex size-4 shrink-0 items-center justify-center">
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}

View file

@ -0,0 +1,92 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { memo } from "react";
import type { DiscoverRow } from "../types";
import { CardCarousel } from "./card-carousel";
import {
MODEL_CARD_HEIGHT_PX,
MODEL_CARD_WIDTH_PX,
ModelCard,
} from "./model-card";
const SKELETON_KEYS = ["s0", "s1", "s2", "s3", "s4"] as const;
function HubSectionRowSkeleton() {
return (
<div className="flex gap-4 overflow-hidden pb-4 pt-2">
{SKELETON_KEYS.map((key) => (
<Skeleton
key={key}
className="shrink-0 rounded-[20px]"
style={{ width: MODEL_CARD_WIDTH_PX, height: MODEL_CARD_HEIGHT_PX }}
/>
))}
</div>
);
}
export const HubSectionRow = memo(function HubSectionRow({
title,
rows,
onSelect,
onOpenList,
deviceType,
isDataset,
isLoading,
}: {
title: string;
rows: DiscoverRow[];
onSelect: (id: string) => void;
onOpenList: () => void;
deviceType: string | null;
isDataset: boolean;
isLoading: boolean;
}) {
const showSkeleton = isLoading && rows.length === 0;
if (!showSkeleton && rows.length === 0) {
return null;
}
return (
<section aria-label={title} className="group/carousel">
<h2 className="mb-3">
<button
type="button"
onClick={onOpenList}
aria-label={`See all ${title}`}
className="hub-section-title group/section -mx-1 inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1 text-[18px] font-semibold tracking-[-0.02em] text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{title}
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className="hub-section-chevron size-4 text-muted-foreground"
/>
</button>
</h2>
{showSkeleton ? (
<HubSectionRowSkeleton />
) : (
<CardCarousel
items={rows}
getKey={(row) => row.id}
itemWidth={MODEL_CARD_WIDTH_PX}
itemHeight={MODEL_CARD_HEIGHT_PX}
ariaLabel={title}
renderItem={(row) => (
<ModelCard
row={row}
deviceType={deviceType}
isDataset={isDataset}
onSelect={onSelect}
/>
)}
/>
)}
</section>
);
});

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