Merge remote-tracking branch 'origin/main' into dg-onto-main
This commit is contained in:
commit
643b829367
55 changed files with 1446 additions and 402 deletions
10
install.ps1
10
install.ps1
|
|
@ -1876,7 +1876,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.2" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.3" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core
|
||||
# to the matching version (no-torch-runtime.txt below
|
||||
|
|
@ -1890,7 +1890,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.2" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.3" unsloth-zoo }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
|
|
@ -1937,7 +1937,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.2" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.3" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
|
||||
|
|
@ -1949,7 +1949,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.2" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.3" unsloth-zoo }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
|
|
@ -1977,7 +1977,7 @@ shell.Run cmd, 0, False
|
|||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.2" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.3" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
|
|
|
|||
10
install.sh
10
install.sh
|
|
@ -2405,7 +2405,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# to prevent transitive torch resolution.
|
||||
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.2" unsloth-zoo
|
||||
"unsloth>=2026.6.3" unsloth-zoo
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core to the
|
||||
# matching version (no-torch-runtime.txt below is --no-deps).
|
||||
# All transitive deps are torch-free.
|
||||
|
|
@ -2418,7 +2418,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
else
|
||||
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.2" unsloth-zoo
|
||||
"unsloth>=2026.6.3" unsloth-zoo
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "overlaying local repo (editable)..."
|
||||
|
|
@ -2622,7 +2622,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.6.2" unsloth-zoo
|
||||
"unsloth>=2026.6.3" unsloth-zoo
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
run_install_cmd "install pydantic (with deps for compatible core)" \
|
||||
uv pip install --python "$_VENV_PY" pydantic
|
||||
|
|
@ -2640,7 +2640,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth "unsloth>=2026.6.2" unsloth-zoo
|
||||
--upgrade-package unsloth "unsloth>=2026.6.3" unsloth-zoo
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -2672,7 +2672,7 @@ else
|
|||
tauri_log "STEP" "Installing Unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.2" --torch-backend=auto
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.3" --torch-backend=auto
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ triton = [
|
|||
]
|
||||
|
||||
huggingfacenotorch = [
|
||||
"unsloth_zoo>=2026.6.2",
|
||||
"unsloth_zoo>=2026.6.3",
|
||||
"wheel>=0.42.0",
|
||||
"packaging",
|
||||
"numpy",
|
||||
|
|
@ -91,7 +91,7 @@ huggingfacenotorch = [
|
|||
]
|
||||
huggingface = [
|
||||
"unsloth[huggingfacenotorch]",
|
||||
"unsloth_zoo>=2026.6.2",
|
||||
"unsloth_zoo>=2026.6.3",
|
||||
"torchvision",
|
||||
"unsloth[triton]",
|
||||
]
|
||||
|
|
@ -581,7 +581,7 @@ colab-ampere-torch220 = [
|
|||
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
|
||||
]
|
||||
colab-new = [
|
||||
"unsloth_zoo>=2026.6.2",
|
||||
"unsloth_zoo>=2026.6.3",
|
||||
"packaging",
|
||||
"tyro",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from utils.hardware import (
|
|||
get_visible_gpu_count,
|
||||
)
|
||||
from core.inference.audio_codecs import AudioCodecManager
|
||||
from core.inference.runtime_context import runtime_context_length
|
||||
from io import StringIO
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -405,6 +406,10 @@ class InferenceBackend:
|
|||
|
||||
# Reject CPU/disk offload for audio models too
|
||||
raise_if_offloaded(self.models[model_name]["model"], device_map, "Inference")
|
||||
self.models[model_name]["context_length"] = runtime_context_length(
|
||||
self.models[model_name].get("model"),
|
||||
max_seq_length,
|
||||
)
|
||||
|
||||
self.active_model_name = model_name
|
||||
self.loading_models.discard(model_name)
|
||||
|
|
@ -485,6 +490,10 @@ class InferenceBackend:
|
|||
self.models[model_name]["tokenizer"] = tokenizer
|
||||
|
||||
raise_if_offloaded(self.models[model_name]["model"], device_map, "Inference")
|
||||
self.models[model_name]["context_length"] = runtime_context_length(
|
||||
self.models[model_name].get("model"),
|
||||
max_seq_length,
|
||||
)
|
||||
|
||||
self._load_chat_template_info(model_name)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ instead of torch/transformers for model loading and generation.
|
|||
|
||||
import threading
|
||||
from typing import Optional, Generator
|
||||
from core.inference.runtime_context import runtime_context_length
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -175,6 +176,7 @@ class MLXInferenceBackend:
|
|||
"is_audio": False,
|
||||
"audio_type": None,
|
||||
"has_audio_input": False,
|
||||
"context_length": runtime_context_length(self._model, max_seq_length),
|
||||
}
|
||||
# Capture chat_template_info so the worker IPC reply ships it back and
|
||||
# the route layer classifies capabilities like the other paths.
|
||||
|
|
|
|||
|
|
@ -727,6 +727,7 @@ class InferenceOrchestrator:
|
|||
"is_audio": model_info.get("is_audio", False),
|
||||
"audio_type": model_info.get("audio_type"),
|
||||
"has_audio_input": model_info.get("has_audio_input", False),
|
||||
"context_length": model_info.get("context_length"),
|
||||
}
|
||||
# Mirror chat_template_info so routes can classify caps
|
||||
# without re-entering the subprocess.
|
||||
|
|
|
|||
22
studio/backend/core/inference/runtime_context.py
Normal file
22
studio/backend/core/inference/runtime_context.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Runtime context length helpers shared by inference backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def runtime_context_length(model: Any, fallback: Optional[int] = None) -> Optional[int]:
|
||||
"""Return the effective context length Unsloth attached to a loaded model."""
|
||||
for value in (getattr(model, "max_seq_length", None), fallback):
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
try:
|
||||
value_int = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value_int > 0:
|
||||
return value_int
|
||||
return None
|
||||
|
|
@ -315,6 +315,18 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
"audio_type": getattr(mc, "audio_type", None),
|
||||
"has_audio_input": getattr(mc, "has_audio_input", False),
|
||||
}
|
||||
try:
|
||||
_bm = getattr(backend, "models", {}) or {}
|
||||
_entry = (
|
||||
_bm.get(mc.identifier)
|
||||
or _bm.get(getattr(backend, "active_model_name", None))
|
||||
or {}
|
||||
)
|
||||
_context_length = _entry.get("context_length")
|
||||
if _context_length is not None:
|
||||
model_info["context_length"] = int(_context_length)
|
||||
except Exception as _ctx_exc:
|
||||
logger.warning("context_length forward failed: %s", _ctx_exc)
|
||||
# Forward chat_template_info so the parent can classify capabilities.
|
||||
try:
|
||||
_bm = getattr(backend, "models", {}) or {}
|
||||
|
|
@ -881,6 +893,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
name: {
|
||||
"is_vision": info.get("is_vision", False),
|
||||
"is_lora": info.get("is_lora", False),
|
||||
"context_length": info.get("context_length"),
|
||||
}
|
||||
for name, info in backend.models.items()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ class LoadResponse(BaseModel):
|
|||
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
|
||||
)
|
||||
context_length: Optional[int] = Field(
|
||||
None, description = "Model's native context length (from GGUF metadata)"
|
||||
None, description = "Runtime context length in tokens for the loaded model"
|
||||
)
|
||||
max_context_length: Optional[int] = Field(
|
||||
None, description = "Maximum context length currently available on this hardware"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ from utils.models import extract_model_size_b as _extract_model_size_b
|
|||
from utils.api_errors import openai_error_body, anthropic_error_body
|
||||
|
||||
|
||||
def _positive_int_or_none(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
value_int = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value_int if value_int > 0 else None
|
||||
|
||||
|
||||
def _install_httpcore_asyncgen_silencer() -> None:
|
||||
"""Silence benign httpx/httpcore asyncgen GC noise on Python 3.13.
|
||||
|
||||
|
|
@ -1391,6 +1401,7 @@ async def load_model(
|
|||
reasoning_always_on = _sf_flags["reasoning_always_on"],
|
||||
supports_preserve_thinking = _sf_flags["supports_preserve_thinking"],
|
||||
supports_tools = _sf_flags["supports_tools"],
|
||||
context_length = _positive_int_or_none(_model_info.get("context_length")),
|
||||
chat_template = _chat_template,
|
||||
)
|
||||
|
||||
|
|
@ -1733,6 +1744,7 @@ async def load_model(
|
|||
reasoning_always_on = _sf_flags["reasoning_always_on"],
|
||||
supports_preserve_thinking = _sf_flags["supports_preserve_thinking"],
|
||||
supports_tools = _sf_flags["supports_tools"],
|
||||
context_length = _positive_int_or_none(_model_info.get("context_length")),
|
||||
chat_template = _chat_template,
|
||||
)
|
||||
|
||||
|
|
@ -2122,6 +2134,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)):
|
|||
reasoning_always_on = _sf_flags["reasoning_always_on"],
|
||||
supports_preserve_thinking = _sf_flags["supports_preserve_thinking"],
|
||||
supports_tools = _sf_flags["supports_tools"],
|
||||
context_length = _positive_int_or_none(model_info.get("context_length")),
|
||||
chat_template = chat_template,
|
||||
llama_cpp_supports_mtp = _supports_mtp,
|
||||
llama_cpp_prebuilt_stale = _stale,
|
||||
|
|
@ -4628,28 +4641,38 @@ def _openai_model_objects() -> list[dict]:
|
|||
"created": _created,
|
||||
"owned_by": "local",
|
||||
}
|
||||
# Extension fields: the real per-request window (post /props readback)
|
||||
# so clients can budget/compact against the enforced limit.
|
||||
if llama_backend.context_length:
|
||||
entry["context_length"] = llama_backend.context_length
|
||||
if llama_backend.max_context_length:
|
||||
entry["max_context_length"] = llama_backend.max_context_length
|
||||
_ctx = _positive_int_or_none(getattr(llama_backend, "context_length", None))
|
||||
if _ctx is not None:
|
||||
entry["context_length"] = _ctx
|
||||
_max_ctx = _positive_int_or_none(getattr(llama_backend, "max_context_length", None))
|
||||
if _max_ctx is not None:
|
||||
entry["max_context_length"] = _max_ctx
|
||||
_native_ctx = _positive_int_or_none(getattr(llama_backend, "native_context_length", None))
|
||||
if _native_ctx is not None:
|
||||
entry["native_context_length"] = _native_ctx
|
||||
models.append(entry)
|
||||
|
||||
# Check Unsloth backend
|
||||
backend = get_inference_backend()
|
||||
if backend.active_model_name:
|
||||
model_info = backend.models.get(backend.active_model_name, {})
|
||||
entry = {
|
||||
"id": backend.active_model_name,
|
||||
"object": "model",
|
||||
"created": _created,
|
||||
"owned_by": "local",
|
||||
}
|
||||
_sf_ctx = getattr(backend, "context_length", None) or getattr(
|
||||
backend, "max_seq_length", None
|
||||
)
|
||||
if _sf_ctx:
|
||||
entry["context_length"] = _sf_ctx
|
||||
_ctx = _positive_int_or_none(model_info.get("context_length"))
|
||||
if _ctx is None:
|
||||
for _candidate in (
|
||||
getattr(backend, "context_length", None),
|
||||
getattr(backend, "max_seq_length", None),
|
||||
):
|
||||
_ctx = _positive_int_or_none(_candidate)
|
||||
if _ctx is not None:
|
||||
break
|
||||
if _ctx is not None:
|
||||
entry["context_length"] = _ctx
|
||||
models.append(entry)
|
||||
|
||||
return models
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class LlamaUpdateJob(BaseModel):
|
|||
from_tag: Optional[str] = None
|
||||
to_tag: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.")
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
|
@ -40,7 +41,9 @@ class LlamaUpdateStatusResponse(BaseModel):
|
|||
False,
|
||||
description = "True when the install came from an Unsloth prebuilt (has a marker).",
|
||||
)
|
||||
update_available: bool = Field(False, description = "True when installed_tag != latest_tag.")
|
||||
update_available: bool = Field(
|
||||
False, description = "True when the latest release is genuinely newer than the install."
|
||||
)
|
||||
stale: bool = Field(
|
||||
False, description = "Update available AND install older than the staleness threshold."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -232,6 +232,9 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
public internet. Synchronous so output lands between the banner URLs and the
|
||||
stop hint. Bounded at ~15s; failures swallowed (verifier failing != Studio
|
||||
failing). Only meaningful for a wildcard bind."""
|
||||
global _public_reachable
|
||||
# Reset to "unknown" each run; set True/False only when the probe decides.
|
||||
_public_reachable = None
|
||||
import ipaddress
|
||||
import json
|
||||
import time
|
||||
|
|
@ -324,12 +327,14 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
|
||||
print("", flush = True)
|
||||
if ok_nodes:
|
||||
_public_reachable = True
|
||||
print(
|
||||
f"{ok_c} Reachability check: {url}/ is reachable from the "
|
||||
f"public internet ({ok_nodes}/{total} probe nodes connected).{reset}",
|
||||
flush = True,
|
||||
)
|
||||
elif err_nodes:
|
||||
_public_reachable = False
|
||||
print(
|
||||
f"{err_c} Reachability check: {url}/ is NOT reachable from "
|
||||
f"the public internet ({err_nodes}/{total} probe nodes failed).{reset}",
|
||||
|
|
@ -422,7 +427,9 @@ def _print_cloudflare_line() -> None:
|
|||
"""Print the Cloudflare quick-tunnel URL for 0.0.0.0 binds, if one is up.
|
||||
|
||||
Reads the module-level URL set by ``run_server``. Prints nothing when the
|
||||
tunnel is disabled or failed -- failures are silently ignored.
|
||||
tunnel is disabled or failed -- failures are silently ignored. When the public
|
||||
reachability probe just failed (``_public_reachable is False``) but the tunnel
|
||||
is up, reword to point the user at the Cloudflare link as the way in.
|
||||
"""
|
||||
if not _cloudflare_url:
|
||||
return
|
||||
|
|
@ -430,7 +437,10 @@ def _print_cloudflare_line() -> None:
|
|||
|
||||
accent = "\033[38;5;150;1m"
|
||||
reset = "\033[0m"
|
||||
line = f" Secure link access via Cloudflare: {_cloudflare_url}"
|
||||
if _public_reachable is False:
|
||||
line = f" Use the secure link access via Cloudflare instead: {_cloudflare_url}"
|
||||
else:
|
||||
line = f" Secure link access via Cloudflare: {_cloudflare_url}"
|
||||
print(f"{accent}{line}{reset}" if stdout_supports_color() else line)
|
||||
|
||||
|
||||
|
|
@ -622,6 +632,12 @@ _shutdown_event = None
|
|||
# None when there is no tunnel (loopback, disabled, or a silently-ignored failure).
|
||||
_cloudflare_url = None
|
||||
|
||||
# Public reachability from the last _verify_global_reachability run, read by the
|
||||
# Cloudflare banner line. True when the public ip:port probe confirmed reachable,
|
||||
# False when it confirmed NOT reachable, None when the probe did not run or could
|
||||
# not decide (timeout, blocked, private address).
|
||||
_public_reachable = None
|
||||
|
||||
|
||||
_DEFAULT_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import importlib.util
|
|||
import io
|
||||
import sys
|
||||
import tarfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -423,3 +424,56 @@ def test_run_server_gates_tunnel_on_wildcard():
|
|||
source = _RUN_PY.read_text()
|
||||
assert "_cloudflare_enabled" in source
|
||||
assert 'host == "0.0.0.0"' in source
|
||||
|
||||
|
||||
def _run_print_cloudflare_line(monkeypatch, *, cloudflare_url, public_reachable):
|
||||
"""Exec the real _print_cloudflare_line source in isolation (run.py has heavy
|
||||
deps), with the two module globals injected and startup_banner stubbed."""
|
||||
src = _RUN_PY.read_text()
|
||||
tree = ast.parse(src)
|
||||
func_src = next(
|
||||
ast.get_source_segment(src, n)
|
||||
for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "_print_cloudflare_line"
|
||||
)
|
||||
stub = types.ModuleType("startup_banner")
|
||||
stub.stdout_supports_color = lambda: False
|
||||
monkeypatch.setitem(sys.modules, "startup_banner", stub)
|
||||
captured: list[str] = []
|
||||
ns = {
|
||||
"_cloudflare_url": cloudflare_url,
|
||||
"_public_reachable": public_reachable,
|
||||
"print": lambda *a, **k: captured.append(" ".join(str(x) for x in a)),
|
||||
}
|
||||
exec(compile(func_src, "<print_cloudflare_line>", "exec"), ns)
|
||||
ns["_print_cloudflare_line"]()
|
||||
return "\n".join(captured)
|
||||
|
||||
|
||||
def test_cloudflare_line_reworded_when_public_unreachable(monkeypatch):
|
||||
out = _run_print_cloudflare_line(
|
||||
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = False
|
||||
)
|
||||
assert "Use the secure link access via Cloudflare instead: https://x.trycloudflare.com" in out
|
||||
|
||||
|
||||
def test_cloudflare_line_default_wording_when_reachable(monkeypatch):
|
||||
out = _run_print_cloudflare_line(
|
||||
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = True
|
||||
)
|
||||
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
|
||||
assert "Use the secure link" not in out
|
||||
|
||||
|
||||
def test_cloudflare_line_default_wording_when_unknown(monkeypatch):
|
||||
# Probe did not run / could not decide -> keep the existing wording.
|
||||
out = _run_print_cloudflare_line(
|
||||
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = None
|
||||
)
|
||||
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
|
||||
assert "Use the secure link" not in out
|
||||
|
||||
|
||||
def test_cloudflare_line_prints_nothing_without_tunnel(monkeypatch):
|
||||
out = _run_print_cloudflare_line(monkeypatch, cloudflare_url = None, public_reachable = False)
|
||||
assert out == ""
|
||||
|
|
|
|||
|
|
@ -32,14 +32,19 @@ if resolve_lemonade_rocm_choice is None or _LEMONADE_GFX_FAMILIES is None:
|
|||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _clear_lemonade_release_cache():
|
||||
"""Prevent cross-test pollution of the lemonade release lru_cache when
|
||||
future tests vary the fetch_json mock return value."""
|
||||
"""Prevent cross-test pollution of the lemonade release lru_cache and
|
||||
selection-log dedup set when tests vary the fetch_json mock return value."""
|
||||
_cache = getattr(_mod, "_fetch_lemonade_release_cached", None)
|
||||
_logged: set | None = getattr(_mod, "_lemonade_selection_logged", None)
|
||||
if _cache is not None and hasattr(_cache, "cache_clear"):
|
||||
_cache.cache_clear()
|
||||
if _logged is not None:
|
||||
_logged.clear()
|
||||
yield
|
||||
if _cache is not None and hasattr(_cache, "cache_clear"):
|
||||
_cache.cache_clear()
|
||||
if _logged is not None:
|
||||
_logged.clear()
|
||||
|
||||
|
||||
_STUB_TAG = "b1262"
|
||||
|
|
|
|||
|
|
@ -21,12 +21,25 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
|
||||
class _NoopLogger:
|
||||
"""structlog-style logger: every method swallows positional + kwargs.
|
||||
|
||||
A stdlib logging.Logger rejects structlog's keyword fields (e.g.
|
||||
``logger.warning(msg, error=...)``), which leaked into the update module's
|
||||
error path and failed only when this file's stub loaded first.
|
||||
"""
|
||||
|
||||
def __getattr__(self, _name):
|
||||
return lambda *a, **k: None
|
||||
|
||||
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
_loggers_stub.get_logger = lambda *a, **k: _NoopLogger()
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
_structlog_stub = _types.ModuleType("structlog")
|
||||
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
|
||||
_structlog_stub.get_logger = lambda *a, **k: _NoopLogger()
|
||||
sys.modules.setdefault("structlog", _structlog_stub)
|
||||
|
||||
import pytest
|
||||
|
|
@ -51,6 +64,11 @@ def _write_marker(install_dir: Path, **overrides) -> Path:
|
|||
.replace("+00:00", "Z"),
|
||||
}
|
||||
payload.update(overrides)
|
||||
# The installer always writes `tag` and `release_tag` from the same release
|
||||
# (a normalized base vs the full release tag), so keep the pair consistent
|
||||
# when a test overrides only `tag`.
|
||||
if "tag" in overrides and "release_tag" not in overrides:
|
||||
payload["release_tag"] = overrides["tag"]
|
||||
install_dir.mkdir(parents = True, exist_ok = True)
|
||||
(install_dir / "UNSLOTH_PREBUILT_INFO.json").write_text(json.dumps(payload))
|
||||
return install_dir / "UNSLOTH_PREBUILT_INFO.json"
|
||||
|
|
@ -303,3 +321,115 @@ def test_format_stale_warning_singular_day():
|
|||
msg = fr.format_stale_warning({"installed_tag": "b9190", "latest_tag": "b9300", "age_days": 1})
|
||||
assert "1 day" in msg
|
||||
assert "1 days" not in msg
|
||||
|
||||
|
||||
# parse_base_build / is_behind.
|
||||
|
||||
|
||||
def test_parse_base_build():
|
||||
assert fr.parse_base_build("b9596") == 9596
|
||||
assert fr.parse_base_build(" b9596 ") == 9596
|
||||
assert fr.parse_base_build("b9596-mix-e6f2453") == 9596 # mix suffix doesn't defeat it
|
||||
assert fr.parse_base_build("9596") is None
|
||||
assert fr.parse_base_build("master-abc") is None
|
||||
assert fr.parse_base_build("") is None
|
||||
assert fr.parse_base_build(None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"installed, latest, expected",
|
||||
[
|
||||
(
|
||||
"b9596-mix-e6f2453",
|
||||
"b9596-mix-e6f2453",
|
||||
False,
|
||||
), # already on the mix latest -> not behind
|
||||
("b9596", "b9594", False), # latest is an older build -> downgrade guard
|
||||
("b9596", "b9594-mix-xxx", False), # older mix latest -> still guarded
|
||||
("b9500", "b9596-mix-e6f2453", True), # newer base -> behind
|
||||
("b9596-mix-aaa", "b9596-mix-bbb", True), # new mix at same base -> behind
|
||||
("b9596", "b9596-mix-bbb", True), # clean -> mix at same base -> behind
|
||||
("b9596-mix-aaa", "b9596", False), # bare base never supersedes a mix install
|
||||
("b9596", "b9596", False), # identical -> not behind
|
||||
(" b9596 ", "b9596", False), # whitespace-only diff -> not behind
|
||||
("master-abc", "master-def", True), # non-bNNNN both -> plain inequality
|
||||
("master-abc", "master-abc", False),
|
||||
(None, "b9596", False),
|
||||
("b9596", None, False),
|
||||
],
|
||||
)
|
||||
def test_is_behind(installed, latest, expected):
|
||||
assert fr.is_behind(installed, latest) is expected
|
||||
|
||||
|
||||
def test_check_prebuilt_freshness_not_behind_on_mix_latest(monkeypatch, tmp_path):
|
||||
# Installed the mix latest: marker base tag b9596, full release_tag with sha,
|
||||
# GitHub latest is that same full tag. Must not report behind (sticky bug).
|
||||
install_dir = tmp_path / "llama.cpp"
|
||||
_write_marker(install_dir, tag = "b9596", release_tag = "b9596-mix-e6f2453")
|
||||
bin_path = _fake_binary(install_dir, layout = "root")
|
||||
monkeypatch.setattr(
|
||||
fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
|
||||
)
|
||||
info = fr.check_prebuilt_freshness(str(bin_path))
|
||||
assert info["behind"] is False
|
||||
assert info["stale"] is False
|
||||
|
||||
|
||||
def test_check_prebuilt_freshness_downgrade_guard(monkeypatch, tmp_path):
|
||||
# A lagging latest (older build than installed) must never read as behind/stale.
|
||||
install_dir = tmp_path / "llama.cpp"
|
||||
_write_marker(
|
||||
install_dir,
|
||||
tag = "b9585",
|
||||
installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 30))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
)
|
||||
bin_path = _fake_binary(install_dir, layout = "root")
|
||||
monkeypatch.setattr(fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
||||
info = fr.check_prebuilt_freshness(str(bin_path))
|
||||
assert info["behind"] is False
|
||||
assert info["stale"] is False
|
||||
|
||||
|
||||
def test_fetch_latest_release_tag_uses_publish_time(monkeypatch):
|
||||
# Resolves newest by published_at (like the installer), skips drafts/prereleases,
|
||||
# and does NOT just take GitHub's first/`/releases/latest` item.
|
||||
import urllib.request
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, payload):
|
||||
self._p = json.dumps(payload).encode()
|
||||
|
||||
def read(self):
|
||||
return self._p
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
payload = [
|
||||
{
|
||||
"tag_name": "b9518",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
"published_at": "2026-06-04T21:11:19Z",
|
||||
},
|
||||
{
|
||||
"tag_name": "b9596-mix-e6f2453",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
"published_at": "2026-06-11T22:50:41Z",
|
||||
},
|
||||
{
|
||||
"tag_name": "b9999-draft",
|
||||
"draft": True,
|
||||
"prerelease": False,
|
||||
"published_at": "2026-06-12T00:00:00Z",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout = 5.0: _Resp(payload))
|
||||
assert fr._fetch_latest_release_tag("unslothai/llama.cpp") == "b9596-mix-e6f2453"
|
||||
|
|
|
|||
|
|
@ -28,23 +28,75 @@ import utils.llama_cpp_update as upd # noqa: E402
|
|||
MARKER = "UNSLOTH_PREBUILT_INFO.json"
|
||||
|
||||
|
||||
class _FakeInstallerPopen:
|
||||
"""Stands in for the streamed installer process in _run_update."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cmd,
|
||||
*,
|
||||
returncode = 0,
|
||||
lines = None,
|
||||
on_start = None,
|
||||
captured_kwargs = None,
|
||||
**kwargs,
|
||||
):
|
||||
if captured_kwargs is not None:
|
||||
captured_kwargs.update(kwargs)
|
||||
if on_start is not None:
|
||||
on_start(list(cmd))
|
||||
self.returncode = returncode
|
||||
self.stdout = iter(lines or [])
|
||||
|
||||
def wait(self):
|
||||
return self.returncode
|
||||
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
|
||||
def _patch_installer_popen(
|
||||
monkeypatch,
|
||||
*,
|
||||
returncode = 0,
|
||||
lines = None,
|
||||
on_start = None,
|
||||
captured_kwargs = None,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
upd.subprocess,
|
||||
"Popen",
|
||||
lambda cmd, **kw: _FakeInstallerPopen(
|
||||
cmd,
|
||||
returncode = returncode,
|
||||
lines = lines,
|
||||
on_start = on_start,
|
||||
captured_kwargs = captured_kwargs,
|
||||
**kw,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write_install(
|
||||
dir_: Path,
|
||||
tag: str,
|
||||
repo: str = "unslothai/llama.cpp",
|
||||
asset: str | None = None,
|
||||
release_tag: str | None = None,
|
||||
) -> str:
|
||||
"""Create a fake prebuilt install tree and return the llama-server path.
|
||||
|
||||
``asset`` is the bundle filename recorded in the marker; omit it to model an
|
||||
older marker that predates asset-based ROCm forwarding (backward compat)."""
|
||||
older marker that predates asset-based ROCm forwarding (backward compat).
|
||||
``release_tag`` is the full release tag (e.g. a ``b9596-mix-<sha>`` mix
|
||||
build); defaults to ``tag`` for a plain prebuilt."""
|
||||
bin_dir = dir_ / "build" / "bin"
|
||||
bin_dir.mkdir(parents = True, exist_ok = True)
|
||||
binary = bin_dir / "llama-server"
|
||||
binary.write_text("#!/bin/sh\necho stub\n")
|
||||
marker = {
|
||||
"tag": tag,
|
||||
"release_tag": tag,
|
||||
"release_tag": release_tag or tag,
|
||||
"published_repo": repo,
|
||||
"installed_at_utc": "2020-01-01T00:00:00Z",
|
||||
"bundle_profile": "cuda13-newer",
|
||||
|
|
@ -57,10 +109,13 @@ def _write_install(
|
|||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _clean_state(monkeypatch):
|
||||
def _clean_state(monkeypatch, tmp_path):
|
||||
freshness.reset_caches()
|
||||
upd._reset_job_for_tests()
|
||||
upd._resolve_memo.clear()
|
||||
# Isolate the freshness disk cache so the suite never writes the real
|
||||
# ~/.unsloth cache (the default when storage_roots can't be imported).
|
||||
monkeypatch.setattr(freshness, "_cache_dir", lambda: tmp_path / ".freshness_cache")
|
||||
# Deterministic markerless paths: no host-pinned binary, no custom dir.
|
||||
monkeypatch.delenv("LLAMA_SERVER_PATH", raising = False)
|
||||
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
|
||||
|
|
@ -260,14 +315,15 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path):
|
|||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
cmd = list(cmd)
|
||||
# Status polls probe `llama-server --version`; keep the installer argv.
|
||||
if "--version" in cmd:
|
||||
return _Proc()
|
||||
captured["cmd"] = cmd
|
||||
_write_install(install_dir, "b9585") # installer writes the marker
|
||||
assert "--version" in cmd # only status polls still use run()
|
||||
return _Proc()
|
||||
|
||||
def _on_start(cmd):
|
||||
captured["cmd"] = cmd
|
||||
_write_install(install_dir, "b9585") # installer writes the marker
|
||||
|
||||
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
||||
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True, res
|
||||
|
|
@ -298,21 +354,27 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
|
|||
stdout = "installed"
|
||||
stderr = ""
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
cmd = list(cmd)
|
||||
# Status polls probe `llama-server --version`; keep the installer argv.
|
||||
if "--version" in cmd:
|
||||
return _Proc()
|
||||
def _on_start(cmd):
|
||||
captured["cmd"] = cmd
|
||||
# Simulate the installer writing a new marker with the latest tag.
|
||||
_write_install(install_dir, "b9518")
|
||||
return _Proc()
|
||||
|
||||
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
||||
popen_kwargs: dict = {}
|
||||
_patch_installer_popen(
|
||||
monkeypatch,
|
||||
lines = [
|
||||
"[llama-prebuilt] resolving release\n",
|
||||
"Downloading llama.zip: 35.0% (12.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
|
||||
"Downloading llama.zip: 80.0% (28.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
|
||||
],
|
||||
on_start = _on_start,
|
||||
captured_kwargs = popen_kwargs,
|
||||
)
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True
|
||||
assert res["job"]["from_tag"] == "b9493"
|
||||
assert res["job"]["progress"] == 0.0
|
||||
|
||||
# Wait for the background worker.
|
||||
deadline = time.time() + 10
|
||||
|
|
@ -328,6 +390,10 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
|
|||
assert str(install_dir) in captured["cmd"]
|
||||
assert "--llama-tag" in captured["cmd"] and "latest" in captured["cmd"]
|
||||
assert "unslothai/llama.cpp" in captured["cmd"]
|
||||
# Progress lines were parsed and success pins progress at 1.0.
|
||||
assert job["progress"] == 1.0
|
||||
# The worker asks the installer for fine-grained progress milestones.
|
||||
assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
|
||||
|
||||
|
||||
def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
|
||||
|
|
@ -335,13 +401,9 @@ def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
|
|||
binary = _write_install(install_dir, "b9493")
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
||||
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
||||
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
||||
|
||||
class _Proc:
|
||||
returncode = 2
|
||||
stdout = ""
|
||||
stderr = "boom: network error"
|
||||
|
||||
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
|
||||
_patch_installer_popen(monkeypatch, returncode = 2, lines = ["boom: network error\n"])
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True
|
||||
|
|
@ -410,14 +472,15 @@ def _capture_install_cmd(
|
|||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
cmd = list(cmd)
|
||||
# Status polls probe `llama-server --version`; keep the installer argv.
|
||||
if "--version" in cmd:
|
||||
return _Proc()
|
||||
captured["cmd"] = cmd
|
||||
_write_install(install_dir, latest, repo = repo, asset = asset)
|
||||
assert "--version" in cmd # only status polls still use run()
|
||||
return _Proc()
|
||||
|
||||
def _on_start(cmd):
|
||||
captured["cmd"] = cmd
|
||||
_write_install(install_dir, latest, repo = repo, asset = asset)
|
||||
|
||||
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
||||
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True, res
|
||||
|
|
@ -535,18 +598,12 @@ def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path):
|
|||
|
||||
seen = {}
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stdout = "ok"
|
||||
stderr = ""
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
def _on_start(cmd):
|
||||
# The maintenance flag must be set while the installer runs.
|
||||
seen["flag_during_install"] = backend._llama_update_in_progress
|
||||
_write_install(install_dir, "b9518")
|
||||
return _Proc()
|
||||
|
||||
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
||||
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True
|
||||
|
|
@ -567,16 +624,12 @@ def test_update_clears_maintenance_flag_on_installer_failure(monkeypatch, tmp_pa
|
|||
binary = _write_install(install_dir, "b9493")
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
||||
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
||||
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
||||
|
||||
backend = _FakeBackend()
|
||||
_inject_backend(monkeypatch, backend)
|
||||
|
||||
class _Proc:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "boom"
|
||||
|
||||
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
|
||||
_patch_installer_popen(monkeypatch, returncode = 1, lines = ["boom\n"])
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True
|
||||
|
|
@ -606,16 +659,7 @@ def test_update_fails_open_when_backend_unavailable(monkeypatch, tmp_path):
|
|||
monkeypatch.setitem(sys.modules, "routes", routes_pkg)
|
||||
monkeypatch.setitem(sys.modules, "routes.inference", inference_mod)
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stdout = "ok"
|
||||
stderr = ""
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
_write_install(install_dir, "b9518")
|
||||
return _Proc()
|
||||
|
||||
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
||||
_patch_installer_popen(monkeypatch, on_start = lambda cmd: _write_install(install_dir, "b9518"))
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True
|
||||
|
|
@ -759,3 +803,41 @@ def test_start_update_source_build_refuses_when_newer(monkeypatch, tmp_path):
|
|||
res = upd.start_update()
|
||||
assert res["started"] is False
|
||||
assert res["reason"] == "up_to_date"
|
||||
|
||||
|
||||
# --- mix-tag detection + apply guard (the reported banner bug) ---
|
||||
|
||||
|
||||
def test_status_not_offered_on_mix_latest(monkeypatch, tmp_path):
|
||||
# Installed the mix latest; GitHub latest is that same full tag -> no banner.
|
||||
binary = _write_install(tmp_path / "llama.cpp", "b9596", release_tag = "b9596-mix-e6f2453")
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
||||
monkeypatch.setattr(
|
||||
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
|
||||
)
|
||||
st = upd.get_update_status()
|
||||
assert st["update_available"] is False
|
||||
assert st["installed_tag"] == "b9596"
|
||||
assert st["latest_tag"] == "b9596-mix-e6f2453"
|
||||
|
||||
|
||||
def test_status_not_offered_when_latest_lags(monkeypatch, tmp_path):
|
||||
# A lagging latest (older build than installed) must never be offered.
|
||||
binary = _write_install(tmp_path / "llama.cpp", "b9585")
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
||||
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
||||
st = upd.get_update_status()
|
||||
assert st["update_available"] is False
|
||||
|
||||
|
||||
def test_start_update_marked_refuses_when_not_behind(monkeypatch, tmp_path):
|
||||
# A direct POST / stale banner must not reinstall when already on the latest.
|
||||
binary = _write_install(tmp_path / "llama.cpp", "b9596", release_tag = "b9596-mix-e6f2453")
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
||||
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
||||
monkeypatch.setattr(
|
||||
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
|
||||
)
|
||||
res = upd.start_update()
|
||||
assert res["started"] is False
|
||||
assert res["reason"] == "up_to_date"
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ class TestPydanticModels:
|
|||
def test_load_response_has_field(self):
|
||||
"""Field exists in LoadResponse.model_fields."""
|
||||
assert "native_context_length" in LoadResponse.model_fields
|
||||
assert "context_length" in LoadResponse.model_fields
|
||||
|
||||
def test_load_response_defaults_none(self):
|
||||
"""Omitting native_context_length defaults to None."""
|
||||
|
|
@ -319,6 +320,7 @@ class TestPydanticModels:
|
|||
def test_status_response_has_field(self):
|
||||
"""Field exists in InferenceStatusResponse.model_fields."""
|
||||
assert "native_context_length" in InferenceStatusResponse.model_fields
|
||||
assert "context_length" in InferenceStatusResponse.model_fields
|
||||
|
||||
def test_status_response_has_chat_template_field(self):
|
||||
"""Status includes chat_template so the UI can rehydrate after refresh."""
|
||||
|
|
@ -347,6 +349,18 @@ class TestPydanticModels:
|
|||
roundtripped = LoadResponse.model_validate_json(resp.model_dump_json())
|
||||
assert roundtripped.native_context_length == 131072
|
||||
|
||||
def test_context_length_roundtrip(self):
|
||||
"""Runtime context_length serializes for non-GGUF/hub models."""
|
||||
resp = LoadResponse(
|
||||
status = "loaded",
|
||||
model = "test",
|
||||
display_name = "Test",
|
||||
inference = {},
|
||||
context_length = 8192,
|
||||
)
|
||||
roundtripped = LoadResponse.model_validate_json(resp.model_dump_json())
|
||||
assert roundtripped.context_length == 8192
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# D. TestRouteCompleteness -- source-level verification
|
||||
|
|
@ -408,6 +422,16 @@ class TestRouteCompleteness:
|
|||
"native_context_length" not in block
|
||||
), f"Non-GGUF LoadResponse should not set native_context_length:\n{block[:200]}"
|
||||
|
||||
def test_non_gguf_load_responses_set_runtime_context_length(self):
|
||||
"""Non-GGUF LoadResponse blocks report runtime context_length."""
|
||||
blocks = self._find_construction_blocks("LoadResponse")
|
||||
non_gguf = [b for b in blocks if "is_gguf = True" not in b and "is_gguf=True" not in b]
|
||||
assert non_gguf, "Expected at least one non-GGUF LoadResponse block"
|
||||
for block in non_gguf:
|
||||
assert (
|
||||
"context_length" in block
|
||||
), f"Non-GGUF LoadResponse should set context_length:\n{block[:200]}"
|
||||
|
||||
def test_status_path(self):
|
||||
"""InferenceStatusResponse construction with llama_backend has the field."""
|
||||
blocks = self._find_construction_blocks("InferenceStatusResponse")
|
||||
|
|
@ -420,6 +444,21 @@ class TestRouteCompleteness:
|
|||
found
|
||||
), "No InferenceStatusResponse block with llama_backend has native_context_length"
|
||||
|
||||
def test_non_gguf_status_path_reports_runtime_context_length(self):
|
||||
"""Non-GGUF InferenceStatusResponse reports context_length from model_info."""
|
||||
blocks = self._find_construction_blocks("InferenceStatusResponse")
|
||||
found = False
|
||||
for block in blocks:
|
||||
if "is_gguf = False" in block and "context_length" in block:
|
||||
found = True
|
||||
break
|
||||
assert found, "No non-GGUF InferenceStatusResponse block with context_length"
|
||||
|
||||
def test_openai_models_listing_reports_context_length(self):
|
||||
"""/v1/models includes context_length when the backend knows it."""
|
||||
assert 'entry["context_length"]' in self._source
|
||||
assert 'model_info.get("context_length")' in self._source
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# E. TestEdgeCases
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -104,11 +105,18 @@ def _save_disk_cache(repo: str, latest_tag: Optional[str]) -> None:
|
|||
|
||||
|
||||
def _fetch_latest_release_tag(repo: str, timeout: float = 5.0) -> Optional[str]:
|
||||
"""GitHub API call. None on any failure (offline, rate-limited, etc)."""
|
||||
"""Newest published release tag for `repo`, by publish time.
|
||||
|
||||
Resolves "latest" the way install_llama_prebuilt.py does (newest
|
||||
non-draft/non-prerelease by ``published_at``), NOT via GitHub's
|
||||
``/releases/latest`` pointer. That pointer sorts by commit date and can lag
|
||||
behind the build the installer actually installs, so detection and apply
|
||||
disagreed -- the cause of the downgrade/sticky banner. None on any failure
|
||||
(offline, rate-limited, etc)."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||||
url = f"https://api.github.com/repos/{repo}/releases?per_page=30"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "unsloth-studio-freshness-check",
|
||||
|
|
@ -128,8 +136,21 @@ def _fetch_latest_release_tag(repo: str, timeout: float = 5.0) -> Optional[str]:
|
|||
) as exc:
|
||||
logger.debug("freshness fetch failed", repo = repo, error = str(exc))
|
||||
return None
|
||||
tag = data.get("tag_name")
|
||||
return tag if isinstance(tag, str) and tag else 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 "")
|
||||
return newest["tag_name"]
|
||||
|
||||
|
||||
def latest_published_release(repo: str, *, force_refresh: bool = False) -> Optional[str]:
|
||||
|
|
@ -172,19 +193,58 @@ def _parse_installed_at(value: object) -> Optional[datetime]:
|
|||
return dt
|
||||
|
||||
|
||||
def parse_base_build(tag: object) -> Optional[int]:
|
||||
"""Numeric base build from a release tag. Handles both a plain ``bNNNN`` and
|
||||
a mix-build tag like ``b9596-mix-<sha>`` (anchored at the start, so the mix
|
||||
suffix doesn't defeat it). None for anything not starting with ``bNNNN``."""
|
||||
if not isinstance(tag, str):
|
||||
return None
|
||||
m = re.match(r"b(\d+)", tag.strip())
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def is_behind(installed: Optional[str], latest: Optional[str]) -> bool:
|
||||
"""Whether `installed` is genuinely behind `latest`, comparing the FULL
|
||||
release identity (so a mix build can legitimately be the latest) with a
|
||||
base-build guard so a lagging GitHub /releases/latest can never read as an
|
||||
update or a downgrade.
|
||||
|
||||
- identical tags -> not behind (clears the sticky banner post-update)
|
||||
- higher base build on `latest` -> behind; lower -> NOT behind (downgrade guard)
|
||||
- same base build: a different/new mix -> behind, but a bare ``bNNNN`` never
|
||||
supersedes a mix build (extra PRs) at that base -> not behind
|
||||
- non-bNNNN tags -> behind (plain inequality, since they already differ)
|
||||
"""
|
||||
if not installed or not latest:
|
||||
return False
|
||||
installed, latest = installed.strip(), latest.strip()
|
||||
if installed == latest:
|
||||
return False
|
||||
ib, lb = parse_base_build(installed), parse_base_build(latest)
|
||||
if ib is None or lb is None:
|
||||
return True
|
||||
if lb != ib:
|
||||
return lb > ib
|
||||
# Same base build, different tags: offer a mix (latest carries a suffix), but
|
||||
# never offer a bare base over a mix install at the same base.
|
||||
return latest != f"b{lb}"
|
||||
|
||||
|
||||
def check_prebuilt_freshness(
|
||||
binary_path: Optional[str],
|
||||
*,
|
||||
threshold_days: int = STALENESS_THRESHOLD_DAYS,
|
||||
now: Optional[datetime] = None,
|
||||
) -> dict:
|
||||
"""Returns {has_marker, stale, installed_tag, latest_tag,
|
||||
"""Returns {has_marker, stale, behind, installed_tag, latest_tag,
|
||||
installed_at_utc, age_days, published_repo, threshold_days}.
|
||||
stale = True iff installed != latest AND age >= threshold.
|
||||
Fails open on missing data (stale stays False)."""
|
||||
behind = installed genuinely older than latest (see is_behind).
|
||||
stale = behind AND age >= threshold.
|
||||
Fails open on missing data (behind/stale stay False)."""
|
||||
out: dict = {
|
||||
"has_marker": False,
|
||||
"stale": False,
|
||||
"behind": False,
|
||||
"installed_tag": None,
|
||||
"latest_tag": None,
|
||||
"installed_at_utc": None,
|
||||
|
|
@ -196,16 +256,25 @@ def check_prebuilt_freshness(
|
|||
if not marker:
|
||||
return out
|
||||
out["has_marker"] = True
|
||||
# Display prefers the normalized base ("tag"); comparison below prefers the
|
||||
# full "release_tag" -- deliberately opposite fallbacks.
|
||||
out["installed_tag"] = marker.get("tag") or marker.get("release_tag")
|
||||
out["installed_at_utc"] = marker.get("installed_at_utc")
|
||||
out["published_repo"] = marker.get("published_repo")
|
||||
|
||||
# The marker records both a normalized base tag ("tag", e.g. b9596) and the
|
||||
# full release tag ("release_tag", e.g. b9596-mix-<sha>). Compare against the
|
||||
# FULL identity, since GitHub /releases/latest returns the full tag_name --
|
||||
# comparing the normalized base against the full latest is what produced the
|
||||
# permanent "downgrade" banner on every mix release.
|
||||
installed_full = marker.get("release_tag") or marker.get("tag")
|
||||
repo = out["published_repo"]
|
||||
if not repo or not out["installed_tag"]:
|
||||
if not repo or not installed_full:
|
||||
return out
|
||||
latest = latest_published_release(repo)
|
||||
out["latest_tag"] = latest
|
||||
if not latest or latest == out["installed_tag"]:
|
||||
out["behind"] = is_behind(installed_full, latest)
|
||||
if not out["behind"]:
|
||||
return out
|
||||
|
||||
installed_at = _parse_installed_at(out["installed_at_utc"])
|
||||
|
|
|
|||
|
|
@ -59,10 +59,17 @@ _job: dict = {
|
|||
"from_tag": None,
|
||||
"to_tag": None,
|
||||
"error": None,
|
||||
"progress": None,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
}
|
||||
|
||||
# Matches the installer's download progress lines, e.g.
|
||||
# "Downloading x.zip: 35.0% (12.3 MiB/35.1 MiB) at 8.2 MiB/s".
|
||||
_PROGRESS_LINE_RE = re.compile(r"(\d+(?:\.\d+)?)%\s*\(")
|
||||
# The download dominates the update; extract/validate fill the last slice.
|
||||
_DOWNLOAD_PROGRESS_CEILING = 0.95
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
|
@ -279,9 +286,10 @@ def get_update_status(*, force_refresh: bool = False) -> dict:
|
|||
freshness = check_prebuilt_freshness(binary)
|
||||
installed = freshness.get("installed_tag")
|
||||
latest = freshness.get("latest_tag")
|
||||
update_available = bool(
|
||||
freshness.get("has_marker") and installed and latest and installed != latest
|
||||
)
|
||||
# `behind` compares the full release identity with a base-build guard, so a
|
||||
# lagging /releases/latest or a mix-tagged latest can't show a false update
|
||||
# (see llama_cpp_freshness.is_behind).
|
||||
update_available = bool(freshness.get("has_marker") and freshness.get("behind"))
|
||||
|
||||
with _job_lock:
|
||||
job = dict(_job)
|
||||
|
|
@ -357,19 +365,55 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
]
|
||||
cmd.extend(_rocm_install_args(asset))
|
||||
logger.info("llama update: installing", cmd = " ".join(cmd))
|
||||
proc = subprocess.run(
|
||||
# Stream the installer output so download percent lines feed
|
||||
# job["progress"]; finer milestones via UNSLOTH_PROGRESS_PERCENT_STEP.
|
||||
env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5")
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
capture_output = True,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
timeout = _INSTALL_TIMEOUT_SECONDS,
|
||||
env = env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
|
||||
raise RuntimeError(f"installer exited {proc.returncode}: {tail or 'no output'}")
|
||||
timed_out = threading.Event()
|
||||
|
||||
# New UNSLOTH_PREBUILT_INFO.json is on disk; drop caches so the next
|
||||
# status read reflects the freshly installed tag.
|
||||
def _kill_on_timeout() -> None:
|
||||
timed_out.set()
|
||||
proc.kill()
|
||||
|
||||
watchdog = threading.Timer(_INSTALL_TIMEOUT_SECONDS, _kill_on_timeout)
|
||||
watchdog.daemon = True
|
||||
watchdog.start()
|
||||
tail_lines: list[str] = []
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
tail_lines.append(line)
|
||||
if len(tail_lines) > 80:
|
||||
del tail_lines[0]
|
||||
m = _PROGRESS_LINE_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
fraction = min(float(m.group(1)) / 100.0, 1.0) * _DOWNLOAD_PROGRESS_CEILING
|
||||
with _job_lock:
|
||||
_job["progress"] = max(_job.get("progress") or 0.0, fraction)
|
||||
returncode = proc.wait()
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
if timed_out.is_set():
|
||||
raise RuntimeError(f"installer timed out after {_INSTALL_TIMEOUT_SECONDS}s")
|
||||
if returncode != 0:
|
||||
tail = "".join(tail_lines).strip()[-1500:]
|
||||
raise RuntimeError(f"installer exited {returncode}: {tail or 'no output'}")
|
||||
|
||||
# New UNSLOTH_PREBUILT_INFO.json is on disk; drop in-memory caches and
|
||||
# re-prime the 24h disk freshness cache with the true newest, so the
|
||||
# banner can't linger on a stale same-base value after the swap.
|
||||
reset_caches()
|
||||
try:
|
||||
latest_published_release(repo, force_refresh = True)
|
||||
except Exception as exc: # pragma: no cover - network defensive
|
||||
logger.debug("llama update: post-install freshness refresh failed", error = str(exc))
|
||||
new_marker = read_install_marker(_find_binary())
|
||||
new_tag = (new_marker or {}).get("tag") or (new_marker or {}).get("release_tag")
|
||||
|
||||
|
|
@ -382,6 +426,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
),
|
||||
to_tag = new_tag,
|
||||
error = None,
|
||||
progress = 1.0,
|
||||
finished_at = _utcnow(),
|
||||
)
|
||||
logger.info("llama update: success", to_tag = new_tag)
|
||||
|
|
@ -417,7 +462,24 @@ def start_update() -> dict:
|
|||
"job": get_update_status()["job"],
|
||||
}
|
||||
|
||||
# A job already in flight wins over any freshness re-check below (and skips
|
||||
# its network call). The final lock block re-checks to close the TOCTOU.
|
||||
with _job_lock:
|
||||
if _job["state"] == _JOB_RUNNING:
|
||||
return {"started": False, "reason": "already_running", "job": dict(_job)}
|
||||
|
||||
if marker:
|
||||
# Mirror the detection guard: a direct POST or a stale banner must not
|
||||
# start an install when the latest is not actually newer (force a fresh
|
||||
# check so a stale 24h cache can't wrongly block a real update either).
|
||||
status = get_update_status(force_refresh = True)
|
||||
if not status.get("update_available"):
|
||||
return {
|
||||
"started": False,
|
||||
"reason": "up_to_date",
|
||||
"message": "The installed llama.cpp build is already at the latest prebuilt.",
|
||||
"job": status["job"],
|
||||
}
|
||||
install_dir = _install_dir_for(binary)
|
||||
repo = marker.get("published_repo") or DEFAULT_PUBLISHED_REPO
|
||||
from_tag = marker.get("tag") or marker.get("release_tag")
|
||||
|
|
@ -467,6 +529,7 @@ def start_update() -> dict:
|
|||
from_tag = from_tag,
|
||||
to_tag = None,
|
||||
error = None,
|
||||
progress = 0.0,
|
||||
started_at = _utcnow(),
|
||||
finished_at = None,
|
||||
)
|
||||
|
|
@ -491,6 +554,7 @@ def _reset_job_for_tests() -> None:
|
|||
from_tag = None,
|
||||
to_tag = None,
|
||||
error = None,
|
||||
progress = None,
|
||||
started_at = None,
|
||||
finished_at = None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ _DEV_VERSION = "dev"
|
|||
_GIT_TIMEOUT_SECONDS = 1.0
|
||||
_STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
|
||||
_GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
|
||||
_GIT_BRANCH_RE = re.compile(r"^[0-9A-Za-z._/-]+$")
|
||||
_MAX_VERSION_LENGTH = 64
|
||||
|
||||
|
||||
|
|
@ -71,6 +72,35 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None:
|
|||
return tag if is_valid_studio_release_version(tag) else None
|
||||
|
||||
|
||||
def _git_branch(repo_root: Path) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd = repo_root,
|
||||
check = False,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.DEVNULL,
|
||||
text = True,
|
||||
timeout = _GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
branch = result.stdout.strip()
|
||||
# "HEAD" means detached, e.g. a tag or commit checkout.
|
||||
if (
|
||||
not branch
|
||||
or branch == "HEAD"
|
||||
or len(branch) > _MAX_VERSION_LENGTH
|
||||
or _GIT_BRANCH_RE.fullmatch(branch) is None
|
||||
):
|
||||
return None
|
||||
return branch
|
||||
|
||||
|
||||
def get_studio_version(repo_root: Path | None = None) -> str:
|
||||
"""Return the installed Studio release tag for display, or ``dev``.
|
||||
|
||||
|
|
@ -81,7 +111,10 @@ def get_studio_version(repo_root: Path | None = None) -> str:
|
|||
|
||||
if _is_source_checkout(resolved_repo_root):
|
||||
git_tag = _exact_git_studio_tag(resolved_repo_root)
|
||||
return git_tag if git_tag is not None else _DEV_VERSION
|
||||
if git_tag is not None:
|
||||
return git_tag
|
||||
branch = _git_branch(resolved_repo_root)
|
||||
return f"GitHub {branch}" if branch is not None else _DEV_VERSION
|
||||
|
||||
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
|
||||
if is_valid_studio_release_version(stamped_version):
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import {
|
|||
Logout05Icon,
|
||||
MoreVerticalIcon,
|
||||
Search01Icon,
|
||||
PlusSignIcon,
|
||||
PowerIcon,
|
||||
PencilEdit02Icon,
|
||||
LayoutAlignLeftIcon,
|
||||
|
|
@ -558,7 +559,8 @@ export function AppSidebar() {
|
|||
: "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",
|
||||
variant === "project" ? "pl-[39px]" : "pl-3",
|
||||
// pl-3.5 starts the title at the same x as the Recents label text.
|
||||
variant === "project" ? "pl-[39px]" : "pl-3.5",
|
||||
variant === "project"
|
||||
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
|
||||
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
|
||||
|
|
@ -837,7 +839,28 @@ export function AppSidebar() {
|
|||
navigate({ to: "/projects" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
className="group/projects-item relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="New project"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProjectCreateMoveTarget(null);
|
||||
setProjectNameDraft("");
|
||||
setCreatingProject(true);
|
||||
}}
|
||||
className="sidebar-row-action group-hover/projects-item:opacity-100 group-hover/projects-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
<HugeiconsIcon
|
||||
icon={PlusSignIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-4"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={DashboardCircleIcon}
|
||||
label={t("shell.navigation.hub")}
|
||||
|
|
@ -1065,7 +1088,7 @@ export function AppSidebar() {
|
|||
className="!size-[32px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<div className="flex flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
|
||||
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
|
||||
import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { type ReactElement, useEffect, useRef } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
||||
|
||||
|
|
@ -15,15 +16,19 @@ interface LlamaUpdateBannerProps {
|
|||
|
||||
/**
|
||||
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a
|
||||
* newer prebuilt is detected and stays up until dismissed (click outside / X)
|
||||
* or updated. Clicking Update swaps the prebuilt in place via POST /api/llama/update.
|
||||
* newer prebuilt is detected and stays up until the user explicitly acts on it
|
||||
* (X, Update, or Remind me later). Clicking Update swaps the prebuilt in place
|
||||
* via POST /api/llama/update. Can be turned off entirely in Settings ->
|
||||
* General -> Notifications (on by default).
|
||||
*/
|
||||
export function LlamaUpdateBanner({
|
||||
enabled = true,
|
||||
}: LlamaUpdateBannerProps): ReactElement | null {
|
||||
const { status, visible, applying, apply, dismiss } = useLlamaUpdateCheck({
|
||||
enabled,
|
||||
});
|
||||
const showBannerPref = useShowLlamaUpdateBanner();
|
||||
const { status, visible, applying, apply, dismiss, snooze } =
|
||||
useLlamaUpdateCheck({
|
||||
enabled: enabled && showBannerPref,
|
||||
});
|
||||
|
||||
async function handleUpdate() {
|
||||
const result = await apply();
|
||||
|
|
@ -40,30 +45,12 @@ export function LlamaUpdateBanner({
|
|||
|
||||
const show =
|
||||
visible && status != null && (status.update_available || applying);
|
||||
const bannerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Dismiss when the user clicks anything outside the banner. Kept off while an
|
||||
// update is applying so the progress stays visible.
|
||||
useEffect(() => {
|
||||
if (!show || applying) return;
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (
|
||||
bannerRef.current &&
|
||||
!bannerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [show, applying, dismiss]);
|
||||
const updateProgress = status?.job.progress ?? null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
<motion.div
|
||||
ref={bannerRef}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
|
|
@ -71,12 +58,12 @@ export function LlamaUpdateBanner({
|
|||
className="fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[340px]"
|
||||
data-testid="llama-update-banner"
|
||||
>
|
||||
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="relative overflow-hidden rounded-[24px] bg-white px-4 pb-[22px] pl-6 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-2.5 flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
|
||||
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
|
||||
|
|
@ -97,32 +84,64 @@ export function LlamaUpdateBanner({
|
|||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pr-5">
|
||||
<span className="text-base" aria-hidden="true">
|
||||
🦥
|
||||
</span>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{applying ? "Updating llama.cpp..." : "New llama.cpp prebuilt"}
|
||||
<div className="min-w-0 pr-6">
|
||||
<p className="font-heading text-base font-medium text-foreground">
|
||||
{applying ? "Updating llama.cpp..." : "New llama.cpp version"}
|
||||
</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>
|
||||
</div>
|
||||
<p className="mt-0.5 pl-7 text-xs text-muted-foreground">
|
||||
{status?.installed_tag ?? "unknown"} →{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{status?.latest_tag ?? ""}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-2.5 pl-7">
|
||||
<Button
|
||||
size="sm"
|
||||
className="corner-squircle"
|
||||
onClick={handleUpdate}
|
||||
disabled={applying}
|
||||
data-testid="llama-update-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)
|
||||
: undefined
|
||||
}
|
||||
data-testid="llama-update-progress"
|
||||
>
|
||||
{applying ? "Updating..." : "Update llama.cpp"}
|
||||
</Button>
|
||||
</div>
|
||||
{updateProgress != null && updateProgress > 0 ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-700 ease-out"
|
||||
style={{ width: `${Math.round(updateProgress * 100)}%` }}
|
||||
/>
|
||||
) : (
|
||||
// No percent yet (resolving the release): sweep until the
|
||||
// first download progress arrives.
|
||||
<div className="loading-bar-slide h-full w-1/3 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-auto rounded-full px-3.5 py-2 text-[13px]"
|
||||
onClick={handleUpdate}
|
||||
data-testid="llama-update-button"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-auto rounded-full px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground"
|
||||
onClick={snooze}
|
||||
data-testid="llama-update-snooze-button"
|
||||
>
|
||||
Remind me later
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ function RepairingContent({
|
|||
</div>
|
||||
<div className="mb-10 flex flex-col items-center gap-2">
|
||||
<TealSpinner />
|
||||
<p className="text-sm font-bold text-foreground">Updating existing Studio install...</p>
|
||||
<p className="text-sm font-bold text-foreground">Updating existing Unsloth install...</p>
|
||||
{latest && (
|
||||
<p className="max-w-xs text-center text-xs text-muted-foreground">{latest}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -98,9 +98,27 @@ function TooltipContent({
|
|||
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
|
||||
variant?: TooltipVariant;
|
||||
}) {
|
||||
// Single-line compact tooltips render as a full pill; wrapped ones keep
|
||||
// the squarer corners so tall pills do not look like capsules. A ref
|
||||
// callback measures on mount: Radix mounts the portal content without
|
||||
// re-rendering this wrapper, so an effect here would never see the node.
|
||||
const measureRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
if (!el || variant !== "default") return;
|
||||
const cs = getComputedStyle(el);
|
||||
const lineHeight = Number.parseFloat(cs.lineHeight) || 16;
|
||||
const innerHeight =
|
||||
el.clientHeight -
|
||||
Number.parseFloat(cs.paddingTop) -
|
||||
Number.parseFloat(cs.paddingBottom);
|
||||
el.classList.toggle("rounded-full!", innerHeight < lineHeight * 1.5);
|
||||
},
|
||||
[variant],
|
||||
);
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={measureRef}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
|
||||
const STUDIO_UPDATE_CMD = "unsloth studio update";
|
||||
const STUDIO_INSTALL_UNIX_CMD =
|
||||
"curl -fsSL https://unsloth.ai/install.sh | sh";
|
||||
const STUDIO_INSTALL_WINDOWS_CMD = "irm https://unsloth.ai/install.ps1 | iex";
|
||||
const RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog";
|
||||
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
||||
|
||||
|
|
@ -20,6 +23,11 @@ export function WebUpdateBanner({
|
|||
enabled = true,
|
||||
}: WebUpdateBannerProps): ReactElement | null {
|
||||
const { status, dismiss } = useWebUpdateCheck({ enabled });
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const installCmd =
|
||||
deviceType === "windows"
|
||||
? STUDIO_INSTALL_WINDOWS_CMD
|
||||
: STUDIO_INSTALL_UNIX_CMD;
|
||||
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
|
||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -36,7 +44,7 @@ export function WebUpdateBanner({
|
|||
}
|
||||
|
||||
async function handleCopyCommand() {
|
||||
if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
|
||||
if (!(await copyToClipboard(installCmd))) {
|
||||
return;
|
||||
}
|
||||
setCopiedVersion(status?.latestVersion ?? null);
|
||||
|
|
@ -89,8 +97,8 @@ export function WebUpdateBanner({
|
|||
Package update available: {status.latestVersion}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Installed package: {status.currentVersion}. To update Studio,
|
||||
run this in your terminal, then restart Studio.
|
||||
Installed package: {status.currentVersion}. To update Unsloth,
|
||||
run this in your terminal, then restart Unsloth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,12 +46,28 @@ export async function fetchDeviceType(): Promise<DeviceType> {
|
|||
if (fetched) return usePlatformStore.getState().deviceType;
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/health"));
|
||||
// /api/health only reports the server's device_type to authed callers.
|
||||
// Read the token from storage directly: importing features/auth here
|
||||
// would be an import cycle (auth/session imports this store).
|
||||
const token =
|
||||
typeof window === "undefined"
|
||||
? null
|
||||
: localStorage.getItem("unsloth_auth_token");
|
||||
const res = await fetch(apiUrl("/api/health"), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { device_type?: string; chat_only?: boolean };
|
||||
const deviceType = data.device_type ?? detectLocalPlatform();
|
||||
const chatOnly = data.chat_only ?? false;
|
||||
usePlatformStore.setState({ deviceType, chatOnly, fetched: true });
|
||||
// Cache only a server-reported platform. Unauthenticated responses fall
|
||||
// back to the browser platform, which can differ from the host (WSL,
|
||||
// SSH); keeping fetched=false retries once a token exists.
|
||||
usePlatformStore.setState({
|
||||
deviceType,
|
||||
chatOnly,
|
||||
fetched: data.device_type !== undefined,
|
||||
});
|
||||
return deviceType;
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export async function authFetch(
|
|||
"You appear to be offline. Check your network connection and try again.",
|
||||
);
|
||||
}
|
||||
throw new Error("Studio isn't running -- please relaunch it.");
|
||||
throw new Error("Unsloth isn't running -- please relaunch it.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ let pending: { promise: Promise<boolean>; force: boolean } | null = null;
|
|||
let lastTauriAuthFailure: string | null = null;
|
||||
|
||||
const TAURI_AUTH_FAILURE_FALLBACK =
|
||||
"Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
|
||||
"Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
|
||||
const BACKEND_NOT_READY_MESSAGE = "Backend is not ready";
|
||||
|
||||
function authFailureMessage(error: unknown): string {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const describeMediaError = (error: unknown): string => {
|
|||
return "Dictation could not access the microphone.";
|
||||
}
|
||||
if (error.name === "NotAllowedError") {
|
||||
return "Microphone access is blocked. Allow microphone access for this Studio page, then try again.";
|
||||
return "Microphone access is blocked. Allow microphone access for this Unsloth page, then try again.";
|
||||
}
|
||||
if (error.name === "NotFoundError") {
|
||||
return "No microphone was found for dictation.";
|
||||
|
|
@ -31,7 +31,7 @@ const describeMediaError = (error: unknown): string => {
|
|||
|
||||
const describeSpeechError = (error: string, message?: string): string => {
|
||||
if (error === "not-allowed") {
|
||||
return "Speech recognition was blocked by the browser. Check microphone permissions for this Studio page.";
|
||||
return "Speech recognition was blocked by the browser. Check microphone permissions for this Unsloth page.";
|
||||
}
|
||||
if (error === "service-not-allowed") {
|
||||
return "Speech recognition is blocked by the browser speech service.";
|
||||
|
|
|
|||
|
|
@ -1511,7 +1511,7 @@ export function ChatProvidersSettings({
|
|||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="font-heading text-lg font-semibold">Connections</h1>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
Manage model connections for chat through the Studio proxy.
|
||||
Manage model connections for chat.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ function CollapsibleSection({
|
|||
label,
|
||||
labelHref,
|
||||
headerAction,
|
||||
onLabelClick,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
first = false,
|
||||
|
|
@ -342,6 +343,8 @@ function CollapsibleSection({
|
|||
* nested in a button.
|
||||
*/
|
||||
headerAction?: ReactNode;
|
||||
/** When set, clicking the label runs this instead of toggling collapse. */
|
||||
onLabelClick?: () => void;
|
||||
children?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
first?: boolean;
|
||||
|
|
@ -395,7 +398,7 @@ function CollapsibleSection({
|
|||
<div className={headerClasses}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
onClick={onLabelClick ?? toggle}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center text-left leading-none transition-colors hover:text-nav-fg"
|
||||
>
|
||||
<span className="leading-none">{label}</span>
|
||||
|
|
@ -558,6 +561,9 @@ export function ChatSettingsPanel({
|
|||
const [presetNameInput, setPresetNameInput] = useState(activePreset);
|
||||
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
|
||||
const [systemPromptDraft, setSystemPromptDraft] = useState("");
|
||||
// When the prompt overflows the inline box, clicking opens the popup editor.
|
||||
const systemPromptBoxRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [systemPromptOverflows, setSystemPromptOverflows] = useState(false);
|
||||
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
|
||||
const presets = useMemo(() => {
|
||||
return getOrderedPresets(customPresets);
|
||||
|
|
@ -762,6 +768,16 @@ export function ChatSettingsPanel({
|
|||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = systemPromptBoxRef.current;
|
||||
setSystemPromptOverflows(
|
||||
params.systemPrompt.length > 0 &&
|
||||
el != null &&
|
||||
el.clientHeight > 0 &&
|
||||
el.scrollHeight > el.clientHeight + 1,
|
||||
);
|
||||
}, [params.systemPrompt, open]);
|
||||
|
||||
const settingsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const settingsContent = (
|
||||
|
|
@ -1119,7 +1135,7 @@ export function ChatSettingsPanel({
|
|||
/>
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className="min-h-0 shrink-0 gap-0 self-stretch border-0 py-0 pl-0 !pr-1 has-[>button]:mr-0"
|
||||
className="min-h-0 shrink-0 gap-0 self-stretch border-0 py-0 pl-0 !pr-1 has-[>button]:mr-0 !cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className="!h-7 min-h-7 !w-7 min-w-7 shrink-0 self-center inline-flex items-center justify-center rounded-full border-0 px-0 text-[#a0a097] dark:text-nav-fg pointer-events-none"
|
||||
|
|
@ -1300,6 +1316,7 @@ export function ChatSettingsPanel({
|
|||
<CollapsibleSection
|
||||
label="System Prompt"
|
||||
defaultOpen={true}
|
||||
onLabelClick={openSystemPromptEditor}
|
||||
headerAction={
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
|
|
@ -1326,22 +1343,36 @@ export function ChatSettingsPanel({
|
|||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSystemPromptEditor}
|
||||
aria-label="Edit system prompt"
|
||||
{/* Rounded wrapper clips overflowing text and the scrollbar. */}
|
||||
<div
|
||||
className={cn(
|
||||
"panel-text-surface -mt-1 flex w-full h-20 overflow-hidden cursor-pointer items-start px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed corner-squircle focus-visible:outline-none focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/40",
|
||||
params.systemPrompt
|
||||
? "text-nav-fg"
|
||||
: "text-muted-foreground",
|
||||
"panel-text-surface -mt-1 h-20 w-full overflow-hidden corner-squircle",
|
||||
systemPromptOverflows && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span className="block line-clamp-3 whitespace-pre-wrap break-words">
|
||||
{params.systemPrompt ||
|
||||
"Example: You are a helpful assistant..."}
|
||||
</span>
|
||||
</button>
|
||||
<textarea
|
||||
ref={systemPromptBoxRef}
|
||||
value={params.systemPrompt}
|
||||
onChange={(e) => set("systemPrompt")(e.target.value)}
|
||||
onMouseDown={(e) => {
|
||||
// Overflowing prompt: click opens the popup editor instead.
|
||||
// While focused, clicks still move the caret normally.
|
||||
if (
|
||||
systemPromptOverflows &&
|
||||
document.activeElement !== e.currentTarget
|
||||
) {
|
||||
e.preventDefault();
|
||||
openSystemPromptEditor();
|
||||
}
|
||||
}}
|
||||
placeholder="Example: You are a helpful assistant..."
|
||||
aria-label="System prompt"
|
||||
className={cn(
|
||||
"block size-full resize-none bg-transparent px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed text-nav-fg outline-none placeholder:text-muted-foreground",
|
||||
systemPromptOverflows && "cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection label="Sampling" defaultOpen={true}>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function buildChatTourSteps({
|
|||
body: (
|
||||
<>
|
||||
This selects what’s loaded for inference. Hub = base models. Fine-tuned
|
||||
= trained Studio outputs, including LoRA adapters and full finetunes.
|
||||
= trained Unsloth outputs, including LoRA adapters and full finetunes.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
@ -38,7 +38,7 @@ export function buildChatTourSteps({
|
|||
title: "Two tabs",
|
||||
body: (
|
||||
<>
|
||||
Hub: search Hugging Face models. Fine-tuned: local Studio outputs you’ve
|
||||
Hub: search Hugging Face models. Fine-tuned: local Unsloth outputs you’ve
|
||||
trained or exported. If results look off, compare base vs fine-tuned
|
||||
outputs to see what changed.
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function isExpectedBackgroundChatStorageError(error: unknown): boolean {
|
|||
(error.message === "Invalid or expired token" ||
|
||||
error.message === "Not authenticated" ||
|
||||
error.message === "Request failed (401)" ||
|
||||
error.message === "Studio isn't running -- please relaunch it.")
|
||||
error.message === "Unsloth isn't running -- please relaunch it.")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ function parseErrorText(status: number, body: unknown): string {
|
|||
const detail = (body as { detail?: unknown }).detail;
|
||||
const formatted = formatFastApiDetail(detail);
|
||||
if (status === 405) {
|
||||
return `${formatted || "Method Not Allowed"} - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.`;
|
||||
return `${formatted || "Method Not Allowed"} - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.`;
|
||||
}
|
||||
if (formatted) return formatted;
|
||||
const message = (body as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
}
|
||||
if (status === 405) {
|
||||
return "Method Not Allowed - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.";
|
||||
return "Method Not Allowed - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.";
|
||||
}
|
||||
return `Request failed (${status})`;
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ const DOWNLOAD_TRANSPORT_CAPABILITIES_FALLBACK: DownloadTransportCapabilities =
|
|||
http: { available: true, reason: null },
|
||||
xet: {
|
||||
available: null,
|
||||
reason: "Couldn't verify Xet support with the Studio backend.",
|
||||
reason: "Couldn't verify Xet support with the Unsloth backend.",
|
||||
},
|
||||
};
|
||||
let downloadTransportCapabilitiesCache: DownloadTransportCapabilities | null =
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export async function effectiveTransportMode(
|
|||
return preferred;
|
||||
}
|
||||
const reason =
|
||||
capabilities.xet.reason ?? "Studio will use HTTP downloads instead.";
|
||||
capabilities.xet.reason ?? "Unsloth will use HTTP downloads instead.";
|
||||
if (lastXetUnavailableWarningReason !== reason) {
|
||||
lastXetUnavailableWarningReason = reason;
|
||||
toast.warning("Xet download transport unavailable", {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function formatGitHubSourceMessage(execution: RecipeExecutionRecord): string {
|
|||
return "Collecting repository threads before rows are available.";
|
||||
}
|
||||
if (source.status === "rate_limited") {
|
||||
return source.message ?? "Waiting for GitHub rate limit. Studio will resume automatically.";
|
||||
return source.message ?? "Waiting for GitHub rate limit. Unsloth will resume automatically.";
|
||||
}
|
||||
return source.message ?? "Collecting repository threads before rows are available.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function formatSourceMessage(execution: RecipeExecutionRecord): string {
|
|||
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
|
||||
? ` Waiting ~${formatMetricValue(source.retry_after_sec)}s.`
|
||||
: "";
|
||||
return `Waiting for GitHub rate limit. Studio will resume automatically.${wait}`;
|
||||
return `Waiting for GitHub rate limit. Unsloth will resume automatically.${wait}`;
|
||||
}
|
||||
return source.message ?? "Crawling GitHub source.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ function formatGitHubSourceSummary(
|
|||
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
|
||||
? ` ~${formatMetricValue(source.retry_after_sec)}s`
|
||||
: "";
|
||||
return `Waiting for GitHub rate limit${wait}. Studio will resume automatically.`;
|
||||
return `Waiting for GitHub rate limit${wait}. Unsloth will resume automatically.`;
|
||||
}
|
||||
if (source.status === "retrying") {
|
||||
return source.message ?? "GitHub request failed; retrying automatically.";
|
||||
|
|
|
|||
|
|
@ -334,8 +334,8 @@ export function GithubRepoSeedForm({
|
|||
/>
|
||||
<p id={tokenHelpId} className="text-xs text-muted-foreground">
|
||||
{usingEnvToken
|
||||
? "Studio detected a server env token, so saved/shared recipes can leave this blank."
|
||||
: "Blank is safest for saved/shared recipes because Studio will read the server environment at run time."}
|
||||
? "Unsloth detected a server env token, so saved/shared recipes can leave this blank."
|
||||
: "Blank is safest for saved/shared recipes because Unsloth will read the server environment at run time."}
|
||||
</p>
|
||||
{hasToken && (
|
||||
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
|
|
@ -450,7 +450,7 @@ export function GithubRepoSeedForm({
|
|||
</fieldset>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Backed by Studio's built-in <code>github_repo</code> seed reader. Large
|
||||
Backed by Unsloth's built-in <code>github_repo</code> seed reader. Large
|
||||
repos can take minutes, so start with small limits for previews.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,21 +4,29 @@
|
|||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { useT } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
Copy01Icon,
|
||||
Tick02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const STUDIO_UPDATE_CMD = "unsloth studio update";
|
||||
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
|
||||
const STUDIO_INSTALL_UNIX_CMD =
|
||||
"curl -fsSL https://unsloth.ai/install.sh | sh";
|
||||
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
|
||||
"irm https://unsloth.ai/install.ps1 | iex";
|
||||
const STUDIO_INSTALL_WINDOWS_CMD = "irm https://unsloth.ai/install.ps1 | iex";
|
||||
const STUDIO_LOCAL_PULL_CMD = "git pull --ff-only";
|
||||
const STUDIO_LOCAL_UPDATE_CMD = "unsloth studio update --local";
|
||||
const STUDIO_LOCAL_FALLBACK_UNIX_CMD = "./install.sh --local";
|
||||
const STUDIO_LOCAL_FALLBACK_WINDOWS_CMD = ".\\install.ps1 --local";
|
||||
const STUDIO_LOCAL_INSTALL_UNIX_CMD = "./install.sh --local";
|
||||
const STUDIO_LOCAL_INSTALL_WINDOWS_CMD = ".\\install.ps1 --local";
|
||||
|
||||
const DOCS_INSTALL_URL = "https://unsloth.ai/docs/get-started/install";
|
||||
const DOCS_UPDATING_URL =
|
||||
"https://unsloth.ai/docs/get-started/install/updating";
|
||||
const DOCS_MAC_URL = "https://unsloth.ai/docs/get-started/install/mac";
|
||||
const DOCS_WINDOWS_URL =
|
||||
"https://unsloth.ai/docs/get-started/install/windows-installation";
|
||||
|
||||
export type UpdateShell = "windows" | "unix";
|
||||
export type UpdateInstallSource =
|
||||
|
|
@ -30,15 +38,6 @@ export type UpdateInstallSource =
|
|||
| "unknown";
|
||||
type UpdateInstallSourceState = UpdateInstallSource | "loading";
|
||||
|
||||
function getStudioUpdateInstructionLine(
|
||||
shell: UpdateShell,
|
||||
t: ReturnType<typeof useT>,
|
||||
): string {
|
||||
return shell === "windows"
|
||||
? t("settings.about.update.openPowerShell")
|
||||
: t("settings.about.update.openTerminal");
|
||||
}
|
||||
|
||||
function isLocalInstallSource(
|
||||
installSource?: UpdateInstallSourceState | null,
|
||||
): boolean {
|
||||
|
|
@ -128,6 +127,77 @@ function CopyableCommand({
|
|||
);
|
||||
}
|
||||
|
||||
function DocsLink({
|
||||
href,
|
||||
label,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{label}
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDocsLinks(): ReactElement {
|
||||
const t = useT();
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.docs")}
|
||||
<DocsLink
|
||||
href={DOCS_INSTALL_URL}
|
||||
label={t("settings.about.update.docsInstall")}
|
||||
/>
|
||||
<DocsLink
|
||||
href={DOCS_UPDATING_URL}
|
||||
label={t("settings.about.update.docsUpdating")}
|
||||
/>
|
||||
<DocsLink
|
||||
href={DOCS_MAC_URL}
|
||||
label={t("settings.about.update.docsMac")}
|
||||
/>
|
||||
<DocsLink
|
||||
href={DOCS_WINDOWS_URL}
|
||||
label={t("settings.about.update.docsWindows")}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellToggleButton({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-foreground/[0.08] text-foreground dark:bg-white/[0.12]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
|
||||
export function UpdateStudioInstructions({
|
||||
className,
|
||||
|
|
@ -145,6 +215,9 @@ export function UpdateStudioInstructions({
|
|||
const shell = shellOverride ?? defaultShell;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const windows = shell === "windows";
|
||||
// null means the desktop app: its bundled backend updates through the
|
||||
// built-in updater, so terminal commands would target the wrong install.
|
||||
const desktopManaged = installSource === null;
|
||||
const localInstallSource = isLocalInstallSource(installSource);
|
||||
const checkoutInstallSource =
|
||||
installSource === "editable" || installSource === "local_repo";
|
||||
|
|
@ -163,6 +236,22 @@ export function UpdateStudioInstructions({
|
|||
? { opacity: 1 }
|
||||
: { opacity: 0, y: -2 };
|
||||
|
||||
if (desktopManaged) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
{showTitle ? (
|
||||
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
|
||||
{t("settings.about.update.title")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.desktopManaged")}
|
||||
</p>
|
||||
<UpdateDocsLinks />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
<div
|
||||
|
|
@ -176,42 +265,55 @@ export function UpdateStudioInstructions({
|
|||
{t("settings.about.update.title")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShellOverride("windows")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-emerald-600",
|
||||
)}
|
||||
aria-pressed={windows}
|
||||
>
|
||||
Windows
|
||||
</button>
|
||||
<span className="text-border">/</span>
|
||||
<button
|
||||
type="button"
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<ShellToggleButton
|
||||
active={!windows}
|
||||
label="MacOS / Linux"
|
||||
onClick={() => setShellOverride("unix")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
? "text-muted-foreground hover:text-emerald-600"
|
||||
: "text-foreground",
|
||||
)}
|
||||
aria-pressed={!windows}
|
||||
>
|
||||
macOS/Linux
|
||||
</button>
|
||||
/>
|
||||
<ShellToggleButton
|
||||
active={windows}
|
||||
label="Windows"
|
||||
onClick={() => setShellOverride("windows")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{loadingInstallSource ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.checkingInstall")}
|
||||
</p>
|
||||
) : localInstallSource ? (
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.installIntro")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`install-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
>
|
||||
<CopyableCommand
|
||||
command={
|
||||
windows ? STUDIO_INSTALL_WINDOWS_CMD : STUDIO_INSTALL_UNIX_CMD
|
||||
}
|
||||
copyLabel={
|
||||
windows
|
||||
? t("settings.about.update.installCommandWindows")
|
||||
: t("settings.about.update.installCommandUnix")
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
{loadingInstallSource ? null : localInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{t("settings.about.update.localUpdateHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.localInstallDetected")}
|
||||
</p>
|
||||
|
|
@ -224,16 +326,9 @@ export function UpdateStudioInstructions({
|
|||
command={STUDIO_LOCAL_PULL_CMD}
|
||||
copyLabel={t("settings.about.update.gitPullCommand")}
|
||||
/>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel={t("settings.about.update.localUpdateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.localInstallerFallback")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`local-fallback-${shell}`}
|
||||
key={`local-installer-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
|
|
@ -242,8 +337,8 @@ export function UpdateStudioInstructions({
|
|||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_INSTALL_UNIX_CMD
|
||||
}
|
||||
copyLabel={t("settings.about.update.localInstallerCommand")}
|
||||
/>
|
||||
|
|
@ -261,7 +356,7 @@ export function UpdateStudioInstructions({
|
|||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`source-fallback-${shell}`}
|
||||
key={`source-installer-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
|
|
@ -270,8 +365,8 @@ export function UpdateStudioInstructions({
|
|||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_INSTALL_UNIX_CMD
|
||||
}
|
||||
copyLabel={t("settings.about.update.localInstallerCommand")}
|
||||
/>
|
||||
|
|
@ -282,54 +377,22 @@ export function UpdateStudioInstructions({
|
|||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
<UpdateDocsLinks />
|
||||
</>
|
||||
) : unknownInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.unknownInstall")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.curlOrPypi")}
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{t("settings.about.update.localUpdateHeading")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel={t("settings.about.update.updateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.localCheckout")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel={t("settings.about.update.localUpdateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`instruction-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{getStudioUpdateInstructionLine(shell, t)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<CopyableCommand
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel={t("settings.about.update.updateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.fallbackInstruction")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`fallback-${shell}`}
|
||||
key={`local-installer-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
|
|
@ -338,16 +401,24 @@ export function UpdateStudioInstructions({
|
|||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
|
||||
? STUDIO_LOCAL_INSTALL_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_INSTALL_UNIX_CMD
|
||||
}
|
||||
copyLabel={t("settings.about.update.fallbackCommand")}
|
||||
copyLabel={t("settings.about.update.localInstallerCommand")}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
<UpdateDocsLinks />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
<UpdateDocsLinks />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export function AboutTab() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Studio">
|
||||
<SettingsSection title="Unsloth">
|
||||
<SettingsRow label={t("settings.about.studioVersion")}>
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{studioVersion}
|
||||
|
|
@ -207,6 +207,37 @@ export function AboutTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.about.license.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.about.license.studioLabel")}
|
||||
description={t("settings.about.license.studioDescription")}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-mono text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("settings.about.license.studioLicense")}
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("settings.about.license.libraryLabel")}
|
||||
description={t("settings.about.license.libraryDescription")}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-mono text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("settings.about.license.libraryLicense")}
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.about.dangerZone")}>
|
||||
<SettingsRow
|
||||
destructive={true}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { usePlatformStore } from "@/config/env";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat";
|
||||
import {
|
||||
setShowLlamaUpdateBanner,
|
||||
useShowLlamaUpdateBanner,
|
||||
} from "@/hooks/use-llama-update-pref";
|
||||
import {
|
||||
loadHelperPrecacheSettings,
|
||||
updateHelperPrecacheSettings,
|
||||
|
|
@ -68,6 +72,8 @@ const PREFS_KEYS: string[] = [
|
|||
"unsloth_user_profile",
|
||||
// Guided tour flags
|
||||
"tour:studio:v1",
|
||||
// Update notifications
|
||||
"unsloth_show_llama_update_banner",
|
||||
];
|
||||
|
||||
// Set by resetAllPrefs so the unmount-commit effect skips writing back the
|
||||
|
|
@ -106,6 +112,7 @@ export function GeneralTab() {
|
|||
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
|
||||
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
|
||||
const chatOnly = usePlatformStore((s) => s.chatOnly);
|
||||
const showLlamaUpdates = useShowLlamaUpdateBanner();
|
||||
const redirectTo = `${pathname}${search}`;
|
||||
|
||||
const [draftToken, setDraftToken] = useState(hfToken ?? "");
|
||||
|
|
@ -315,6 +322,20 @@ export function GeneralTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.general.notifications.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.notifications.showLlamaUpdates")}
|
||||
description={t(
|
||||
"settings.general.notifications.showLlamaUpdatesDescription",
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
checked={showLlamaUpdates}
|
||||
onCheckedChange={setShowLlamaUpdateBanner}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.general.uploads.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.uploads.maxUploadSize")}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const studioNavStep: TourStep = {
|
|||
title: "Quick orientation",
|
||||
body: (
|
||||
<>
|
||||
Studio: pick base model, dataset, hyperparams, then start training. After
|
||||
Unsloth: pick base model, dataset, hyperparams, then start training. After
|
||||
you start, you’ll see a Training view with live loss/metrics. Chat is for
|
||||
testing base vs LoRA adapters. Export packages checkpoints for deployment.{" "}
|
||||
<ReadMore href="https://unsloth.ai/docs/get-started/fine-tuning-for-beginners" />
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ import { authFetch, getAuthToken } from "@/features/auth";
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// First check shortly after load, then re-surface as an hourly reminder. The
|
||||
// banner stays up until the user dismisses it (click outside / X) or updates.
|
||||
// banner stays up until the user explicitly acts on it (X, Update, or
|
||||
// Remind me later).
|
||||
const FIRST_CHECK_DELAY_MS = 1000;
|
||||
const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour
|
||||
// "Remind me later" re-surfaces sooner than the hourly reminder.
|
||||
const SNOOZE_DELAY_MS = 15 * 60 * 1000; // ~15 minutes
|
||||
// While an update is applying, poll the job state at this cadence.
|
||||
const JOB_POLL_INTERVAL_MS = 3000;
|
||||
const JOB_POLL_INTERVAL_MS = 1500;
|
||||
|
||||
export interface LlamaUpdateJob {
|
||||
state: "idle" | "running" | "success" | "error";
|
||||
|
|
@ -17,6 +20,8 @@ export interface LlamaUpdateJob {
|
|||
from_tag: string | null;
|
||||
to_tag: string | null;
|
||||
error: string | null;
|
||||
// Download fraction (0..1) while running, 1 on success, null when unknown.
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
export interface LlamaUpdateStatus {
|
||||
|
|
@ -42,6 +47,7 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
|
|||
from_tag: typeof job.from_tag === "string" ? job.from_tag : null,
|
||||
to_tag: typeof job.to_tag === "string" ? job.to_tag : null,
|
||||
error: typeof job.error === "string" ? job.error : null,
|
||||
progress: typeof job.progress === "number" ? job.progress : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -73,9 +79,10 @@ export interface LlamaApplyResult {
|
|||
|
||||
/**
|
||||
* Polls the backend for a newer llama.cpp prebuilt. When one exists, `visible`
|
||||
* becomes true ~1s after load and stays up until the user dismisses it (click
|
||||
* outside / X) or updates; it re-surfaces every ~hour as a reminder. `apply()`
|
||||
* triggers the in-place swap and tracks the job.
|
||||
* becomes true ~1s after load and stays up until the user dismisses it (X),
|
||||
* snoozes it ("Remind me later", ~15 min), or updates; it re-surfaces every
|
||||
* ~hour as a reminder. `apply()` triggers the in-place swap and tracks the
|
||||
* job.
|
||||
*/
|
||||
export function useLlamaUpdateCheck({
|
||||
enabled = true,
|
||||
|
|
@ -84,6 +91,7 @@ export function useLlamaUpdateCheck({
|
|||
const [visible, setVisible] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const snoozeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimer.current) {
|
||||
|
|
@ -161,6 +169,10 @@ export function useLlamaUpdateCheck({
|
|||
clearTimeout(firstTimer);
|
||||
clearInterval(reminder);
|
||||
clearPollTimer();
|
||||
if (snoozeTimer.current) {
|
||||
clearTimeout(snoozeTimer.current);
|
||||
snoozeTimer.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, surfaceIfAvailable, clearPollTimer]);
|
||||
|
||||
|
|
@ -168,6 +180,16 @@ export function useLlamaUpdateCheck({
|
|||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
// Hide now, re-check and re-surface after SNOOZE_DELAY_MS.
|
||||
const snooze = useCallback(() => {
|
||||
setVisible(false);
|
||||
if (snoozeTimer.current) clearTimeout(snoozeTimer.current);
|
||||
snoozeTimer.current = setTimeout(() => {
|
||||
snoozeTimer.current = null;
|
||||
fetchStatus().then(surfaceIfAvailable);
|
||||
}, SNOOZE_DELAY_MS);
|
||||
}, [surfaceIfAvailable]);
|
||||
|
||||
const apply = useCallback(async (): Promise<LlamaApplyResult> => {
|
||||
if (applying) return { ok: false, error: "already running" };
|
||||
setApplying(true);
|
||||
|
|
@ -219,5 +241,6 @@ export function useLlamaUpdateCheck({
|
|||
applying,
|
||||
apply,
|
||||
dismiss,
|
||||
snooze,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
49
studio/frontend/src/hooks/use-llama-update-pref.ts
Normal file
49
studio/frontend/src/hooks/use-llama-update-pref.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// 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 { useSyncExternalStore } from "react";
|
||||
|
||||
// Whether the llama.cpp update banner may appear. On by default; only an
|
||||
// explicit "false" (Settings -> General -> Notifications) disables it.
|
||||
const STORAGE_KEY = "unsloth_show_llama_update_banner";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getShowLlamaUpdateBanner(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) !== "false";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function setShowLlamaUpdateBanner(show: boolean): void {
|
||||
try {
|
||||
if (show) {
|
||||
// Remove rather than store "true" so the default stays on.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(STORAGE_KEY, "false");
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
// Sync toggles made in another tab.
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) listener();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
export function useShowLlamaUpdateBanner(): boolean {
|
||||
return useSyncExternalStore(subscribe, getShowLlamaUpdateBanner);
|
||||
}
|
||||
|
|
@ -56,23 +56,23 @@ function wait(ms: number) {
|
|||
function externalConflictMessage(preflight: DesktopPreflightResult) {
|
||||
if (preflight.reason === "desktop_owned_backend_active") {
|
||||
return preflight.port
|
||||
? `A desktop-owned Studio server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
|
||||
: "A desktop-owned Studio server for this install is already running. Quit the other desktop app instance, then try again.";
|
||||
? `A desktop-owned Unsloth server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
|
||||
: "A desktop-owned Unsloth server for this install is already running. Quit the other desktop app instance, then try again.";
|
||||
}
|
||||
|
||||
if (preflight.reason === "desktop_owned_backend_starting") {
|
||||
return "The desktop-owned Studio backend is still starting. Wait a moment, then try again.";
|
||||
return "The desktop-owned Unsloth backend is still starting. Wait a moment, then try again.";
|
||||
}
|
||||
|
||||
if (preflight.reason?.startsWith("desktop_owned_backend_unmanageable:")) {
|
||||
return preflight.port
|
||||
? `A desktop-owned Studio backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.`
|
||||
: "A desktop-owned Studio backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.";
|
||||
? `A desktop-owned Unsloth backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.`
|
||||
: "A desktop-owned Unsloth backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.";
|
||||
}
|
||||
|
||||
return preflight.port
|
||||
? `A Studio server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
|
||||
: "A Studio server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
|
||||
? `A Unsloth server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
|
||||
: "A Unsloth server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
|
||||
}
|
||||
|
||||
async function waitForManagedServerReady(
|
||||
|
|
@ -248,8 +248,8 @@ export function useTauriBackend() {
|
|||
} else {
|
||||
setBackendError(
|
||||
preflight.disposition === "owned_stale"
|
||||
? "Desktop-owned Studio backend is too old for this desktop app. Run `unsloth studio update`, then restart Studio."
|
||||
: "Managed Studio install is too old. Run `unsloth studio update`.",
|
||||
? "Desktop-owned Unsloth backend is too old for this desktop app. Run `unsloth studio update`, then restart Unsloth."
|
||||
: "Managed Unsloth install is too old. Run `unsloth studio update`.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
|
@ -304,7 +304,7 @@ export function useTauriBackend() {
|
|||
if (msg.includes("already running")) {
|
||||
startingRef.current = false;
|
||||
setBackendError(
|
||||
"Managed server is already running but did not report a port. Restart Studio and try again.",
|
||||
"Managed server is already running but did not report a port. Restart Unsloth and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -596,7 +596,7 @@ export function useTauriBackend() {
|
|||
const detail =
|
||||
event instanceof CustomEvent && typeof event.detail === "string"
|
||||
? event.detail
|
||||
: "Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
|
||||
: "Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
|
||||
setAuthFailure(detail);
|
||||
};
|
||||
window.addEventListener("tauri-auth-failed", onAuthFailed);
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const en = {
|
|||
title: "Settings",
|
||||
dialog: {
|
||||
title: "Settings",
|
||||
description: "Manage your Unsloth Studio preferences.",
|
||||
description: "Manage your Unsloth preferences.",
|
||||
closeAriaLabel: "Close settings",
|
||||
},
|
||||
tabs: {
|
||||
|
|
@ -93,11 +93,11 @@ export const en = {
|
|||
chat: "Chat",
|
||||
connections: "Connections",
|
||||
apiKeys: "API",
|
||||
about: "Help",
|
||||
about: "About",
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
description: "Global preferences for Unsloth Studio.",
|
||||
description: "Global preferences for Unsloth.",
|
||||
account: "Account",
|
||||
huggingFaceToken: "Hugging Face token",
|
||||
huggingFaceTokenDescription:
|
||||
|
|
@ -112,38 +112,44 @@ export const en = {
|
|||
sectionTitle: "Helper LLM",
|
||||
preloadOnStartup: "Pre-cache Helper LLM on startup",
|
||||
preloadOnStartupDescription:
|
||||
"Download and cache the AI Assist helper model in the background when Studio starts. Off by default; AI Assist can still download it on demand when clicked.",
|
||||
"Download the AI Assist helper model in the background on startup. Off by default; AI Assist can still fetch it on demand.",
|
||||
disabledByEnv:
|
||||
"Disabled by UNSLOTH_HELPER_MODEL_DISABLE in the backend environment.",
|
||||
loadError: "Failed to load Helper LLM settings.",
|
||||
saveError: "Failed to save Helper LLM settings.",
|
||||
},
|
||||
notifications: {
|
||||
sectionTitle: "Notifications",
|
||||
showLlamaUpdates: "llama.cpp update notifications",
|
||||
showLlamaUpdatesDescription:
|
||||
"Notify when a newer llama.cpp build is available. Turn off if you only train.",
|
||||
},
|
||||
gettingStarted: "Getting started",
|
||||
startOnboarding: "Start onboarding",
|
||||
startOnboardingDescription:
|
||||
"Open the setup wizard again without changing your account.",
|
||||
"Reopen the setup wizard without changing your account.",
|
||||
startOnboardingAction: "Start onboarding",
|
||||
uploads: {
|
||||
sectionTitle: "Uploads",
|
||||
maxUploadSize: "Training dataset upload cap",
|
||||
maxUploadSizeDescription:
|
||||
"Applies to training dataset uploads. Default is {defaultSize} MB.",
|
||||
"Default is {defaultSize} MB.",
|
||||
},
|
||||
resetPreferences: {
|
||||
sectionTitle: "Danger zone",
|
||||
label: "Reset all local preferences",
|
||||
description:
|
||||
"Clears local-only preferences. Chats, API access, and DB-backed settings are not affected.",
|
||||
"Clears local-only preferences. Chats, API access, and DB-backed settings are kept.",
|
||||
action: "Reset preferences",
|
||||
confirmTitle: "Reset all local preferences?",
|
||||
confirmDescription:
|
||||
"This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed settings are not affected.",
|
||||
"Clears local-only preferences and reloads Unsloth. Chats, API access, and DB-backed settings are kept.",
|
||||
confirmAction: "Reset and reload",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: "Profile",
|
||||
description: "Update how your profile appears in Studio.",
|
||||
description: "How your profile appears in Unsloth.",
|
||||
changePicture: "Change profile picture",
|
||||
displayName: "Display name",
|
||||
nameSaved: "Profile name saved",
|
||||
|
|
@ -163,7 +169,7 @@ export const en = {
|
|||
theme: {
|
||||
title: "Theme",
|
||||
label: "Color scheme",
|
||||
description: "Choose light, dark, or follow your system.",
|
||||
description: "Light, dark, or follow your system.",
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
|
|
@ -171,7 +177,7 @@ export const en = {
|
|||
language: {
|
||||
title: "Language",
|
||||
label: "Display language",
|
||||
description: "Choose the language used by Studio.",
|
||||
description: "The language used by Unsloth.",
|
||||
},
|
||||
layout: {
|
||||
title: "Layout",
|
||||
|
|
@ -182,25 +188,25 @@ export const en = {
|
|||
},
|
||||
chat: {
|
||||
title: "Chat",
|
||||
description: "Manage your chat history stored on this device.",
|
||||
description: "Manage chat history stored on this device.",
|
||||
artifacts: {
|
||||
title: "Artifacts",
|
||||
collapseHtmlBlocks: "Collapse HTML blocks",
|
||||
collapseHtmlBlocksDescription:
|
||||
"Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
|
||||
"Artifacts mode collapses full HTML automatically. Turn on to also collapse fenced HTML documents when Artifacts is off.",
|
||||
allowNetworkAccess: "Allow artifact network access",
|
||||
allowNetworkAccessDescription:
|
||||
"Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
|
||||
"Let artifact previews load scripts, styles, fonts, media, and network resources from CDNs. Keep off for fully offline previews.",
|
||||
},
|
||||
data: "Data",
|
||||
exportHistory: "Export chat history",
|
||||
exportHistoryDescription:
|
||||
"Download all chats and messages as a JSON file.",
|
||||
"Download all chats and messages as JSON.",
|
||||
exportAction: "Export",
|
||||
exportingAction: "Exporting...",
|
||||
exportConversations: "Export Recents and Projects",
|
||||
exportConversationsDescription:
|
||||
"Download Recents only, or Recents plus project chats, as Raw JSONL, CSV, or ShareGPT JSONL, combined or one file per chat.",
|
||||
"Download Recents or Recents plus project chats as Raw JSONL, CSV, or ShareGPT JSONL, combined or per chat.",
|
||||
exportConversationsAction: "Export",
|
||||
exportScopeRecents: "Recents",
|
||||
exportScopeAll: "Recents + Projects",
|
||||
|
|
@ -208,14 +214,14 @@ export const en = {
|
|||
exportPerChatSuffix: "(per chat)",
|
||||
importChats: "Import chats",
|
||||
importChatsDescription:
|
||||
"Add conversations from a JSONL, NDJSON, or CSV export to Recents.",
|
||||
"Import a JSONL, NDJSON, or CSV export into Recents.",
|
||||
importChatsAction: "Import",
|
||||
importNoConversations: "No conversations found in file.",
|
||||
importedOneChat: "Imported 1 conversation to Recents.",
|
||||
importedChatCount: "Imported {count} conversations to Recents.",
|
||||
importFailed: "Import failed.",
|
||||
clearHistory: "Clear chat history",
|
||||
clearHistoryDescription: "Delete local chat history from this device.",
|
||||
clearHistoryDescription: "Delete chat history from this device.",
|
||||
clearAction: "Clear",
|
||||
clearAllChats: "Clear all chats",
|
||||
clearAllChatsDescription: "Permanently delete every chat on this device.",
|
||||
|
|
@ -228,7 +234,7 @@ export const en = {
|
|||
clearOneChatTitle: "Clear 1 chat?",
|
||||
clearChatsTitle: "Clear {count} chats?",
|
||||
clearChatsConfirmDescription:
|
||||
"This permanently deletes every chat and message stored on this device. This cannot be undone.",
|
||||
"Permanently deletes every chat on this device. This cannot be undone.",
|
||||
clearingAction: "Clearing...",
|
||||
clearOneChatAction: "Clear 1 chat",
|
||||
clearChatCountAction: "Clear {count} chats",
|
||||
|
|
@ -251,12 +257,12 @@ export const en = {
|
|||
},
|
||||
connections: {
|
||||
title: "Connections",
|
||||
description: "Manage providers and external service connections.",
|
||||
description: "Manage providers and external connections.",
|
||||
},
|
||||
apiKeys: {
|
||||
title: "API",
|
||||
description:
|
||||
"Access Unsloth programmatically via the OpenAI-compatible API.",
|
||||
"Access Unsloth via the OpenAI-compatible API.",
|
||||
readDocs: "Read the API docs",
|
||||
noAccess: "No API access yet.",
|
||||
newBadge: "New",
|
||||
|
|
@ -296,62 +302,71 @@ export const en = {
|
|||
revokeToken: "Revoke token",
|
||||
revokeTitle: 'Revoke access token "{name}"?',
|
||||
revokeDescription:
|
||||
"Applications using this token will immediately lose access. This cannot be undone.",
|
||||
"Apps using this token immediately lose access. This cannot be undone.",
|
||||
revokeAction: 'Revoke "{name}"',
|
||||
revoking: "Revoking...",
|
||||
},
|
||||
about: {
|
||||
title: "About",
|
||||
description:
|
||||
"Documentation, release notes, feedback, and Studio build info.",
|
||||
studioVersion: "Studio Version",
|
||||
"Docs, release notes, feedback, and build info.",
|
||||
studioVersion: "Unsloth Version",
|
||||
packageVersion: "Package Version",
|
||||
updates: "Updates",
|
||||
updates: "Update",
|
||||
help: "Help",
|
||||
documentation: "Documentation",
|
||||
releaseNotes: "Release notes",
|
||||
whatsNew: "What's new",
|
||||
feedback: "Feedback",
|
||||
reportIssue: "Report an issue",
|
||||
license: {
|
||||
sectionTitle: "License",
|
||||
studioLabel: "Unsloth Studio",
|
||||
studioLicense: "AGPL-3.0",
|
||||
studioDescription:
|
||||
"Open source under the GNU AGPL v3.0.",
|
||||
libraryLabel: "Unsloth Core",
|
||||
libraryLicense: "Apache-2.0",
|
||||
libraryDescription: "Licensed under Apache 2.0.",
|
||||
},
|
||||
dangerZone: "Danger zone",
|
||||
shutDownStudio: "Shut down Unsloth Studio",
|
||||
shutDownStudioDescription:
|
||||
"Stops the Studio server process and ends your session.",
|
||||
"Stops the Unsloth server and ends your session.",
|
||||
shutDown: "Shut down",
|
||||
update: {
|
||||
title: "Update Unsloth Studio",
|
||||
openPowerShell: "Open PowerShell and run:",
|
||||
openTerminal: "Open Terminal and run:",
|
||||
commandText: "{label} text",
|
||||
copied: "Copied",
|
||||
copyCommand: "Copy command",
|
||||
commandCopied: "{label} copied",
|
||||
copyNamedCommand: "Copy {label}",
|
||||
checkingInstall: "Checking how Studio was installed...",
|
||||
checkingInstall: "Checking how Unsloth was installed...",
|
||||
installIntro: "To install or update Unsloth:",
|
||||
localUpdateHeading: "Local update",
|
||||
installCommandUnix: "macOS/Linux install command",
|
||||
installCommandWindows: "Windows install command",
|
||||
localInstallDetected:
|
||||
"Source or local install detected. To avoid replacing it with PyPI, update from the checkout or source you originally installed from.",
|
||||
pullThenUpdate:
|
||||
"Pull latest changes from your Unsloth repo checkout, then update Studio locally:",
|
||||
"Local install detected. Update from your original checkout to avoid replacing it with PyPI.",
|
||||
pullThenUpdate: "Pull the latest changes, then run the local installer:",
|
||||
gitPullCommand: "git pull command",
|
||||
localUpdateCommand: "local update command",
|
||||
localInstallerFallback:
|
||||
"If the Studio update command is unavailable, run the local installer from that checkout:",
|
||||
localInstallerCommand: "local installer command",
|
||||
sourceInstallDetected:
|
||||
"This looks like a source or VCS package install. Reinstall from the original local path or Git URL you used.",
|
||||
"Source or VCS package install detected. Reinstall from the original local path or Git URL.",
|
||||
repoCheckoutFallback:
|
||||
"If you still have the Unsloth repo checkout, run the local installer from that checkout:",
|
||||
restartAfterUpdate:
|
||||
"Restart Studio after updating for changes to take effect.",
|
||||
"If you still have the repo checkout, run the local installer from it:",
|
||||
restartAfterUpdate: "Restart Unsloth after updating.",
|
||||
desktopManaged:
|
||||
"The desktop app keeps its bundled backend updated and will prompt when a new version is available.",
|
||||
unknownInstall:
|
||||
"Studio could not detect how it was installed. Check how you installed Studio first, then choose the matching update path.",
|
||||
curlOrPypi: "For curl or PyPI installs, run:",
|
||||
updateCommand: "update command",
|
||||
"Could not detect how Unsloth was installed. For installer or PyPI installs, use the commands above.",
|
||||
localCheckout:
|
||||
"For local checkout installs, update from that checkout instead and use the local update command:",
|
||||
fallbackInstruction:
|
||||
"If that fails or unsloth studio update is unavailable, run:",
|
||||
fallbackCommand: "fallback command",
|
||||
"For local checkout installs, run the local installer from that checkout:",
|
||||
docs: "Install docs:",
|
||||
docsInstall: "Installation",
|
||||
docsUpdating: "Updating",
|
||||
docsMac: "Mac",
|
||||
docsWindows: "Windows",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export const zhCN = {
|
|||
chat: "聊天",
|
||||
connections: "连接",
|
||||
apiKeys: "API",
|
||||
about: "帮助",
|
||||
about: "关于",
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
|
|
@ -106,6 +106,12 @@ export const zhCN = {
|
|||
chatDefaults: "聊天默认设置",
|
||||
autoTitleNewChats: "自动为新聊天命名",
|
||||
autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
|
||||
notifications: {
|
||||
sectionTitle: "通知",
|
||||
showLlamaUpdates: "llama.cpp 更新通知",
|
||||
showLlamaUpdatesDescription:
|
||||
"有新的 llama.cpp 构建时提醒。如果只用于训练可以关闭。",
|
||||
},
|
||||
gettingStarted: "入门",
|
||||
startOnboarding: "开始引导",
|
||||
startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
|
||||
|
|
@ -124,13 +130,13 @@ export const zhCN = {
|
|||
action: "重置偏好设置",
|
||||
confirmTitle: "重置所有本地偏好设置?",
|
||||
confirmDescription:
|
||||
"这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的设置不会受到影响。",
|
||||
"这会清除仅保存在本地的偏好设置,然后重新加载 Unsloth。聊天、API 访问权限和数据库中的设置不会受到影响。",
|
||||
confirmAction: "重置并重新加载",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: "个人资料",
|
||||
description: "更新你在 Studio 中显示的个人资料。",
|
||||
description: "更新你在 Unsloth 中显示的个人资料。",
|
||||
changePicture: "更换头像",
|
||||
displayName: "显示名称",
|
||||
nameSaved: "个人资料名称已保存",
|
||||
|
|
@ -150,7 +156,7 @@ export const zhCN = {
|
|||
language: {
|
||||
title: "语言",
|
||||
label: "显示语言",
|
||||
description: "选择 Studio 使用的语言。",
|
||||
description: "选择 Unsloth 使用的语言。",
|
||||
},
|
||||
theme: {
|
||||
title: "主题",
|
||||
|
|
@ -274,9 +280,9 @@ export const zhCN = {
|
|||
revoking: "撤销中...",
|
||||
},
|
||||
about: {
|
||||
title: "帮助",
|
||||
description: "文档、发布说明、反馈和 Studio 构建信息。",
|
||||
studioVersion: "Studio 版本",
|
||||
title: "关于",
|
||||
description: "文档、发布说明、反馈和 Unsloth 构建信息。",
|
||||
studioVersion: "Unsloth 版本",
|
||||
packageVersion: "包版本",
|
||||
updates: "更新",
|
||||
help: "帮助",
|
||||
|
|
@ -285,43 +291,52 @@ export const zhCN = {
|
|||
whatsNew: "最新内容",
|
||||
feedback: "反馈",
|
||||
reportIssue: "报告问题",
|
||||
license: {
|
||||
sectionTitle: "许可证",
|
||||
studioLabel: "Unsloth Studio",
|
||||
studioLicense: "AGPL-3.0",
|
||||
studioDescription: "基于 GNU AGPL v3.0 开源。",
|
||||
libraryLabel: "Unsloth Core",
|
||||
libraryLicense: "Apache-2.0",
|
||||
libraryDescription: "基于 Apache License 2.0 许可。",
|
||||
},
|
||||
dangerZone: "危险区域",
|
||||
shutDownStudio: "关闭 Unsloth Studio",
|
||||
shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
|
||||
shutDownStudioDescription: "停止 Unsloth 服务进程并结束你的会话。",
|
||||
shutDown: "关闭",
|
||||
update: {
|
||||
title: "更新 Unsloth Studio",
|
||||
openPowerShell: "打开 PowerShell 并运行:",
|
||||
openTerminal: "打开终端并运行:",
|
||||
commandText: "{label} 文本",
|
||||
copied: "已复制",
|
||||
copyCommand: "复制命令",
|
||||
commandCopied: "{label} 已复制",
|
||||
copyNamedCommand: "复制 {label}",
|
||||
checkingInstall: "正在检查 Studio 的安装方式...",
|
||||
checkingInstall: "正在检查 Unsloth 的安装方式...",
|
||||
installIntro: "安装或更新 Unsloth:",
|
||||
localUpdateHeading: "本地更新",
|
||||
installCommandUnix: "macOS/Linux 安装命令",
|
||||
installCommandWindows: "Windows 安装命令",
|
||||
localInstallDetected:
|
||||
"检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
|
||||
pullThenUpdate:
|
||||
"从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio:",
|
||||
"检测到本地安装。请从最初的 checkout 更新,以免被 PyPI 版本替换。",
|
||||
pullThenUpdate: "拉取最新变更,然后运行本地安装器:",
|
||||
gitPullCommand: "git pull 命令",
|
||||
localUpdateCommand: "本地更新命令",
|
||||
localInstallerFallback:
|
||||
"如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
|
||||
localInstallerCommand: "本地安装器命令",
|
||||
sourceInstallDetected:
|
||||
"这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
|
||||
repoCheckoutFallback:
|
||||
"如果你仍保留 Unsloth 仓库 checkout,请从该 checkout 运行本地安装器:",
|
||||
restartAfterUpdate: "更新后重启 Studio,使变更生效。",
|
||||
restartAfterUpdate: "更新后请重启 Unsloth。",
|
||||
desktopManaged:
|
||||
"桌面应用会自动更新其内置后端,有新版本时会提示。",
|
||||
unknownInstall:
|
||||
"Studio 无法检测安装方式。请先确认你如何安装 Studio,然后选择匹配的更新方式。",
|
||||
curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
|
||||
updateCommand: "更新命令",
|
||||
"Unsloth 无法检测安装方式。如果你使用一键安装器或 PyPI 安装,请使用上面的命令。",
|
||||
localCheckout:
|
||||
"对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
|
||||
fallbackInstruction:
|
||||
"如果失败,或 unsloth studio update 不可用,请运行:",
|
||||
fallbackCommand: "备用命令",
|
||||
"对于本地 checkout 安装,请改为从该 checkout 运行本地安装器:",
|
||||
docs: "安装文档:",
|
||||
docsInstall: "安装",
|
||||
docsUpdating: "更新",
|
||||
docsMac: "Mac",
|
||||
docsWindows: "Windows",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -552,6 +552,7 @@
|
|||
.sidebar-nav-btn[data-state="open"],
|
||||
.group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
|
||||
background-color: var(--nav-surface-hover) !important;
|
||||
|
|
@ -562,6 +563,7 @@
|
|||
.dark .sidebar-nav-btn[data-state="open"],
|
||||
.dark .group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.dark .group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.dark .group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.dark .group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
|
||||
.dark .group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
|
||||
color: #fff !important;
|
||||
|
|
@ -957,6 +959,11 @@
|
|||
border-radius: 14px !important;
|
||||
}
|
||||
|
||||
/* Account menu: a touch rounder than standard list menus. */
|
||||
[data-slot="dropdown-menu-content"].app-user-menu.menu-soft-surface-up {
|
||||
border-radius: 18px !important;
|
||||
}
|
||||
|
||||
/* Every dropdown/menu/select/popover: borderless, chatbox shadow in light,
|
||||
none in dark. !important so it also overrides the bespoke menu shadows. */
|
||||
[data-slot="dropdown-menu-content"],
|
||||
|
|
@ -1078,13 +1085,16 @@
|
|||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])[data-pill-label]:hover::after {
|
||||
content: attr(data-pill-label);
|
||||
@apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-[9px] bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
|
||||
/* Always one nowrap line, so always a full pill. */
|
||||
@apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-full bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
|
||||
}
|
||||
|
||||
/* Compact caret pills (RAG, MCP) open their menu on click instead of
|
||||
toggling off, so keep the icon and skip the X swap. */
|
||||
toggling off, so keep the icon and skip the X swap. No data-active
|
||||
requirement: the off-switch glyph rules below hide the icon on hover
|
||||
even for inactive pills, which left a blank slot in compact mode. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret)[data-active="true"]:hover
|
||||
.composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret):hover
|
||||
.composer-pill-glyph
|
||||
> :not(.composer-pill-x) {
|
||||
opacity: 1;
|
||||
|
|
@ -1512,6 +1522,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Indeterminate loading bar: a 1/3-width segment sweeping the track. */
|
||||
.loading-bar-slide {
|
||||
animation: loading-bar-slide 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-bar-slide {
|
||||
from {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(420%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes artifact-loading-line {
|
||||
0% {
|
||||
transform: translate3d(-125%, 0, 0) scaleX(0.78);
|
||||
|
|
@ -1750,10 +1774,10 @@
|
|||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Composer shadow on the dark background color so toasts
|
||||
do not merge into card-colored surfaces behind them. */
|
||||
/* Dark toasts share the chatbox surface color (.chat-composer-surface
|
||||
uses var(--card) in dark); the composer shadow lifts them off the page. */
|
||||
.dark [data-sonner-toast][data-styled='true'] {
|
||||
background-color: var(--background) !important;
|
||||
background-color: var(--card) !important;
|
||||
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
|
||||
|
|
@ -2099,6 +2123,11 @@
|
|||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
|
||||
.loading-bar-slide {
|
||||
animation-duration: 1.3s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
|
||||
/* Keep the plus/x morph animating under reduced motion (small rotation,
|
||||
like the spinners above). */
|
||||
.unsloth-composer-plus svg {
|
||||
|
|
|
|||
|
|
@ -460,8 +460,14 @@ def is_busy_lock_error(exc: BaseException) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# Status logs default to stderr so resolver modes keep stdout machine-readable
|
||||
# (setup.sh json.load()s the whole stdout). main() flips this for the install
|
||||
# path, where PowerShell otherwise renders stderr as NativeCommandError noise.
|
||||
_LOG_TO_STDOUT = False
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[llama-prebuilt] {message}", file = sys.stderr)
|
||||
print(f"[llama-prebuilt] {message}", file = sys.stdout if _LOG_TO_STDOUT else sys.stderr)
|
||||
|
||||
|
||||
def log_lines(lines: Iterable[str]) -> None:
|
||||
|
|
@ -856,6 +862,16 @@ def format_byte_count(num_bytes: float) -> str:
|
|||
return f"{num_bytes:.1f} B"
|
||||
|
||||
|
||||
def _progress_percent_step() -> int:
|
||||
"""Non-tty milestone granularity. The in-app updater sets
|
||||
UNSLOTH_PROGRESS_PERCENT_STEP=5 to stream finer progress lines."""
|
||||
try:
|
||||
step = int(os.environ.get("UNSLOTH_PROGRESS_PERCENT_STEP", "25"))
|
||||
except ValueError:
|
||||
return 25
|
||||
return min(max(step, 1), 50)
|
||||
|
||||
|
||||
class DownloadProgress:
|
||||
def __init__(self, label: str, total_bytes: int | None) -> None:
|
||||
self.label = label
|
||||
|
|
@ -868,6 +884,7 @@ class DownloadProgress:
|
|||
)
|
||||
self.is_tty = term_ok and self.stream.isatty()
|
||||
self.completed = False
|
||||
self.milestone_step = _progress_percent_step()
|
||||
self.last_milestone_percent = -1
|
||||
self.last_milestone_bytes = 0
|
||||
self.has_rendered_tty_progress = False
|
||||
|
|
@ -918,7 +935,8 @@ class DownloadProgress:
|
|||
should_emit = False
|
||||
if self.total_bytes is not None:
|
||||
percent = int((downloaded_bytes * 100) / max(self.total_bytes, 1))
|
||||
milestone_percent = min((percent // 25) * 25, 100)
|
||||
step = self.milestone_step
|
||||
milestone_percent = min((percent // step) * step, 100)
|
||||
if milestone_percent > self.last_milestone_percent and milestone_percent < 100:
|
||||
self.last_milestone_percent = milestone_percent
|
||||
should_emit = True
|
||||
|
|
@ -3916,6 +3934,12 @@ def _is_trusted_github_release_url(url: str, expected_repo: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# (gfx_target, asset_name) pairs already logged. resolve_lemonade_rocm_choice()
|
||||
# runs twice per install (direct planner + resolve_upstream_asset_choice), so
|
||||
# this stops its selection banner and hash-manifest NOTE printing twice.
|
||||
_lemonade_selection_logged: "set[tuple[str, str]]" = set()
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize = 8)
|
||||
def _fetch_lemonade_release_cached(api_url: str, llama_tag: str) -> "dict | None":
|
||||
"""Cached wrapper around fetch_json for lemonade release lookups.
|
||||
|
|
@ -4022,18 +4046,22 @@ def resolve_lemonade_rocm_choice(
|
|||
# Note: lemonade tags Linux assets with "ubuntu" but the binary is a
|
||||
# generic glibc build that runs on any distro (Arch, Fedora, ...), so
|
||||
# this attempt is selected for all Linux ROCm hosts, not just Ubuntu.
|
||||
log(
|
||||
f"AMD GPU {host.rocm_gfx_target!r} ({gfx_family}) -- "
|
||||
f"trying lemonade-sdk ROCm prebuilt {asset_name} "
|
||||
f"(works on any glibc Linux, not just Ubuntu)"
|
||||
)
|
||||
log(
|
||||
f"NOTE: lemonade-sdk/llamacpp-rocm releases are not covered by the "
|
||||
f"Unsloth approved-hash manifest; download integrity relies on "
|
||||
f"functional validation (llama-bench / llama-server smoke tests) "
|
||||
f"after extraction. Set UNSLOTH_DISABLE_LEMONADE_ROCM=1 to skip "
|
||||
f"lemonade and fall back to the upstream HIP build path."
|
||||
)
|
||||
# Log once per (gfx_target, asset); see _lemonade_selection_logged.
|
||||
log_key = (host.rocm_gfx_target, asset_name)
|
||||
if log_key not in _lemonade_selection_logged:
|
||||
_lemonade_selection_logged.add(log_key)
|
||||
log(
|
||||
f"AMD GPU {host.rocm_gfx_target!r} ({gfx_family}) -- "
|
||||
f"trying lemonade-sdk ROCm prebuilt {asset_name} "
|
||||
f"(works on any glibc Linux, not just Ubuntu)"
|
||||
)
|
||||
log(
|
||||
f"NOTE: lemonade-sdk/llamacpp-rocm releases are not covered by the "
|
||||
f"Unsloth approved-hash manifest; download integrity relies on "
|
||||
f"functional validation (llama-bench / llama-server smoke tests) "
|
||||
f"after extraction. Set UNSLOTH_DISABLE_LEMONADE_ROCM=1 to skip "
|
||||
f"lemonade and fall back to the upstream HIP build path."
|
||||
)
|
||||
return AssetChoice(
|
||||
repo = LEMONADE_ROCM_REPO,
|
||||
tag = release_tag,
|
||||
|
|
@ -7012,6 +7040,9 @@ def main() -> int:
|
|||
raise SystemExit(
|
||||
"install_llama_prebuilt.py: --install-dir is required unless --resolve-llama-tag, --resolve-install-tag, or --resolve-source-build is used"
|
||||
)
|
||||
# Install path only: route status logs to stdout (see _LOG_TO_STDOUT note).
|
||||
global _LOG_TO_STDOUT
|
||||
_LOG_TO_STDOUT = True
|
||||
install_prebuilt(
|
||||
install_dir = Path(args.install_dir).expanduser().resolve(),
|
||||
llama_tag = args.llama_tag,
|
||||
|
|
|
|||
|
|
@ -300,7 +300,14 @@ def test_object_style_rope_scaling_on_config_delegates_correctly():
|
|||
expected = _reference_inv_freq(dict_config, "linear")
|
||||
|
||||
object_config = _make_config({"rope_type": "linear", "factor": 4.0})
|
||||
object_config.rope_scaling = FakeLinearRopeScalingConfig()
|
||||
try:
|
||||
object_config.rope_scaling = FakeLinearRopeScalingConfig()
|
||||
except Exception:
|
||||
pytest.skip(
|
||||
"transformers strict-validates rope_scaling to dict/RopeParameters/None, "
|
||||
"so object-style config.rope_scaling (and the delegation retry it "
|
||||
"exercises) is unreachable on this version."
|
||||
)
|
||||
inv_freq, attention_scaling = _compute_config_rope_inv_freq(
|
||||
object_config, object_config.rope_scaling
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
__version__ = "2026.6.2"
|
||||
__version__ = "2026.6.3"
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTS_BFLOAT16",
|
||||
|
|
|
|||
|
|
@ -1673,12 +1673,26 @@ def _llama3_inv_freq_from_config(
|
|||
return torch.where(is_medium, smoothed, scaled)
|
||||
|
||||
|
||||
def _vanilla_inv_freq_from_config(config, device = "cpu"):
|
||||
"""Unscaled RoPE inv_freq (rope_type 'default'/None), matching the constructor's fallback."""
|
||||
base = _get_rope_theta(config, default = 10000.0)
|
||||
dim = getattr(config, "head_dim", None)
|
||||
if dim is None:
|
||||
dim = int(config.hidden_size // config.num_attention_heads)
|
||||
return 1.0 / (base ** (torch.arange(0, dim, 2, dtype = torch.int64, device = device).float() / dim))
|
||||
|
||||
|
||||
def _compute_config_rope_inv_freq(config, rope_scaling):
|
||||
"""(inv_freq, attention_scaling) per config.rope_scaling via transformers'
|
||||
ROPE_INIT_FUNCTIONS, with an inline llama3 fallback; (None, 1.0) on failure."""
|
||||
original_rope_scaling = rope_scaling
|
||||
rope_scaling = _rope_scaling_as_dict(rope_scaling)
|
||||
rope_type = rope_scaling.get("rope_type", None) or rope_scaling.get("type", None)
|
||||
# "default"/unset means unscaled RoPE. transformers >=5 reports
|
||||
# rope_type="default" for every plain config and dropped "default" from
|
||||
# ROPE_INIT_FUNCTIONS, so compute it directly instead of warning per load.
|
||||
if rope_type in (None, "default"):
|
||||
return _vanilla_inv_freq_from_config(config).to(dtype = torch.float32, device = "cpu"), 1.0
|
||||
try:
|
||||
from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS
|
||||
|
||||
|
|
|
|||
|
|
@ -570,6 +570,19 @@ def _load_model_via_http(
|
|||
raise RuntimeError(f"Model load failed (HTTP {exc.code}): {body}") from exc
|
||||
|
||||
|
||||
def _format_context_length_line(load_result: dict) -> Optional[str]:
|
||||
value = load_result.get("context_length")
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
value_int = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value_int <= 0:
|
||||
return None
|
||||
return f" Context length: {value_int} tokens"
|
||||
|
||||
|
||||
# ── unsloth studio (server) ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -841,7 +854,10 @@ def run(
|
|||
None, "--gguf-variant", help = "GGUF quant variant (e.g. UD-Q4_K_XL)"
|
||||
),
|
||||
max_seq_length: int = typer.Option(
|
||||
0, "--max-seq-length", help = "Max sequence length (0 = model default)"
|
||||
0,
|
||||
"--max-seq-length",
|
||||
"--context-length",
|
||||
help = "Runtime context length in tokens (0 = model default for GGUF; 2048 for hub models)",
|
||||
),
|
||||
load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"),
|
||||
api_key_name: str = typer.Option(
|
||||
|
|
@ -1080,6 +1096,7 @@ def run(
|
|||
|
||||
loaded_model = result.get("model", model)
|
||||
display_variant = f" ({gguf_variant})" if gguf_variant else ""
|
||||
context_length_line = _format_context_length_line(result)
|
||||
|
||||
# 6. Print banner.
|
||||
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
|
||||
|
|
@ -1119,6 +1136,8 @@ def run(
|
|||
if _cf_url:
|
||||
typer.echo(f" Secure link access via Cloudflare: {_cf_url}")
|
||||
typer.echo(f" Model loaded: {loaded_model}{display_variant}")
|
||||
if context_length_line:
|
||||
typer.echo(context_length_line)
|
||||
typer.echo(f" API Key: {api_key}")
|
||||
typer.echo("")
|
||||
typer.echo(" OpenAI / Anthropic SDK base URL:")
|
||||
|
|
@ -1153,6 +1172,8 @@ def run(
|
|||
typer.echo(f"URL: {base_url}")
|
||||
if _cf_url:
|
||||
typer.echo(f"Secure link access via Cloudflare: {_cf_url}")
|
||||
if context_length_line:
|
||||
typer.echo(context_length_line.strip())
|
||||
typer.echo(f"API Key: {api_key}")
|
||||
typer.secho(_tool_notice, fg = _tool_notice_fg, bold = True)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,18 @@ def test_parallel_option_is_registered():
|
|||
assert required in flags, f"flag {required!r} missing from --parallel option"
|
||||
|
||||
|
||||
def test_context_length_alias_is_registered():
|
||||
"""`--context-length` is an operator-facing alias for --max-seq-length."""
|
||||
studio_mod = _load_run_command()
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(studio_mod.run)
|
||||
opt = sig.parameters["max_seq_length"].default
|
||||
flags = set(getattr(opt, "param_decls", None) or [])
|
||||
assert "--max-seq-length" in flags
|
||||
assert "--context-length" in flags
|
||||
|
||||
|
||||
def test_parallel_default_is_four():
|
||||
"""Default must stay at 4 so plain `unsloth studio run` is unchanged."""
|
||||
studio_mod = _load_run_command()
|
||||
|
|
@ -249,6 +261,15 @@ def test_reexec_np_is_first_class_alias(monkeypatch):
|
|||
assert _value_after(argv, "--port") == "8888", argv
|
||||
|
||||
|
||||
def test_reexec_forwards_context_length_alias(monkeypatch):
|
||||
"""Alias should normalize to the existing child --max-seq-length flag."""
|
||||
result, captured = _invoke_run(monkeypatch, _BASE + ["--context-length", "8192"])
|
||||
assert len(captured) == 1, result.output
|
||||
argv = captured[0]["argv"]
|
||||
assert _value_after(argv, "--max-seq-length") == "8192", argv
|
||||
assert "--context-length" not in argv, argv
|
||||
|
||||
|
||||
def test_reexec_mixed_parallel_with_passthrough(monkeypatch):
|
||||
"""--parallel + llama-server pass-through flags must all reach the child."""
|
||||
result, captured = _invoke_run(
|
||||
|
|
@ -262,6 +283,22 @@ def test_reexec_mixed_parallel_with_passthrough(monkeypatch):
|
|||
assert _value_after(argv, "--temp") == "0.7", argv
|
||||
|
||||
|
||||
def test_context_length_banner_line_formats_ints():
|
||||
studio_mod = _load_run_command()
|
||||
assert studio_mod._format_context_length_line({"context_length": 4096}) == (
|
||||
" Context length: 4096 tokens"
|
||||
)
|
||||
assert studio_mod._format_context_length_line({"context_length": "8192"}) == (
|
||||
" Context length: 8192 tokens"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, 0, -1, True, ""])
|
||||
def test_context_length_banner_line_omits_unknown_values(value):
|
||||
studio_mod = _load_run_command()
|
||||
assert studio_mod._format_context_length_line({"context_length": value}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_flag,expected_in_child",
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue