Merge branch 'main' into studio-readme-server-flags
This commit is contained in:
commit
541e3c110e
316 changed files with 21929 additions and 7326 deletions
295
studio/backend/core/inference/api_monitor.py
Normal file
295
studio/backend/core/inference/api_monitor.py
Normal 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
56
studio/backend/core/inference/llama_http.py
Normal file
56
studio/backend/core/inference/llama_http.py
Normal 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
|
||||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
118
studio/backend/core/inference/llama_stats.py
Normal file
118
studio/backend/core/inference/llama_stats.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
260
studio/backend/tests/test_api_monitor.py
Normal file
260
studio/backend/tests/test_api_monitor.py
Normal 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("...")
|
||||
84
studio/backend/tests/test_api_perf_serialization.py
Normal file
84
studio/backend/tests/test_api_perf_serialization.py
Normal 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]
|
||||
152
studio/backend/tests/test_compute_buffer.py
Normal file
152
studio/backend/tests/test_compute_buffer.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
160
studio/backend/tests/test_gguf_xet_fallback_integration.py
Normal file
160
studio/backend/tests/test_gguf_xet_fallback_integration.py
Normal 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"
|
||||
352
studio/backend/tests/test_hf_xet_fallback.py
Normal file
352
studio/backend/tests/test_hf_xet_fallback.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([[]])
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
128
studio/backend/tests/test_llama_stats.py
Normal file
128
studio/backend/tests/test_llama_stats.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1002
studio/backend/tests/test_mtp_vram_budget.py
Normal file
1002
studio/backend/tests/test_mtp_vram_budget.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
|
@ -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"}}]},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
studio/backend/tests/test_torchao_select.py
Normal file
71
studio/backend/tests/test_torchao_select.py
Normal 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
|
||||
209
studio/backend/tests/test_training_xet_fallback.py
Normal file
209
studio/backend/tests/test_training_xet_fallback.py
Normal 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
|
||||
|
|
@ -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() ==========
|
||||
|
||||
|
||||
|
|
|
|||
86
studio/backend/tests/test_validate_model_error.py
Normal file
86
studio/backend/tests/test_validate_model_error.py
Normal 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"
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
405
studio/backend/utils/hf_xet_fallback.py
Normal file
405
studio/backend/utils/hf_xet_fallback.py
Normal 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}'")
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"} →{" "}
|
||||
<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"} →{" "}
|
||||
<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
43
studio/frontend/src/components/ui/info-hint.tsx
Normal file
43
studio/frontend/src/components/ui/info-hint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
145
studio/frontend/src/features/chat/artifacts/html-fences.ts
Normal file
145
studio/frontend/src/features/chat/artifacts/html-fences.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (0–1) 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
155
studio/frontend/src/features/chat/utils/update-thread-message.ts
Normal file
155
studio/frontend/src/features/chat/utils/update-thread-message.ts
Normal 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) || [];
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
149
studio/frontend/src/features/hub/catalog/card-carousel.tsx
Normal file
149
studio/frontend/src/features/hub/catalog/card-carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 Hub→train picker ships; divider pairs with it. */}
|
||||
{(!isDownloaded || downloading || HUB_POST_DOWNLOAD_ACTIONS_VISIBLE) && (
|
||||
<CardDivider />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
81
studio/frontend/src/features/hub/catalog/hub-detail-view.tsx
Normal file
81
studio/frontend/src/features/hub/catalog/hub-detail-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
studio/frontend/src/features/hub/catalog/hub-feed.tsx
Normal file
40
studio/frontend/src/features/hub/catalog/hub-feed.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
92
studio/frontend/src/features/hub/catalog/hub-section-row.tsx
Normal file
92
studio/frontend/src/features/hub/catalog/hub-section-row.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
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
Loading…
Add table
Add a link
Reference in a new issue