unsloth/studio/backend/routes/settings.py
Daniel Han 80c15643cf studio: add a configurable idle TTL that auto-unloads an unused model
A loaded GGUF model stayed resident until a manual unload or process exit, with
no way to set an idle timeout. Add an idle TTL (seconds): when set, a background
task started by the app lifespan unloads the model after it has been idle that
long. 0 disables eviction, preserving the historical behavior.

- utils/model_ttl_settings.py: the setting (app_settings key), the
  UNSLOTH_MODEL_IDLE_TTL env default, and validation (0 to one week).
- LlamaCppBackend tracks last-activity: refreshed on load, on every generation
  request entry, and on every streamed chunk, so a long generation (e.g. a
  minutes-long reasoning stream) is never evicted mid-flight; idle_seconds is
  None when nothing is loaded.
- main.py runs the eviction loop in the lifespan and cancels it on shutdown.
- GET/PUT /api/settings/model-ttl to read/set the TTL at runtime; the response
  also reports the loaded model's current idle and time-to-eviction.

The request-path activity ping goes through a small _note_idle_activity() guard
so a backend that does not implement the hook degrades gracefully instead of
turning a served request into a 500.

Eviction is made race-safe. unload_model sets _cancel_event, which an in-flight
load of a different model watches, so a naive evict could abort that load. The
evictor now goes through LlamaCppBackend.evict_if_idle, which re-checks idle and
in-flight under _serial_load_lock (the lock load_model holds for its whole
duration) and unloads atomically, so it can never run concurrently with a load
or unload. An in-flight request counter (request_in_flight, applied to the chat
and tool generators) additionally blocks eviction while a request is active,
covering the window before the first streamed token when per-chunk activity has
not started refreshing yet.
2026-06-22 15:39:13 +00:00

263 lines
8.6 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from typing import Literal, Optional
from urllib.parse import unquote, urlsplit
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, Field, field_validator
from auth.authentication import get_current_subject
from loggers import get_logger
from utils.utils import safe_error_detail, log_and_http_error
from utils.personalization_settings import (
MAX_AVATAR_DATA_URL_BYTES,
PERSONALIZATION_VERSION,
get_personalization,
set_personalization,
)
from utils.upload_limits import (
MAX_UPLOAD_LIMIT_MB,
MIN_UPLOAD_LIMIT_MB,
default_upload_limit_mb,
get_upload_limit_mb,
set_upload_limit_mb,
upload_limit_bytes,
upload_limit_label,
)
from utils.helper_precache_settings import (
DEFAULT_HELPER_PRECACHE_ENABLED,
get_helper_precache_enabled,
helper_model_disabled_by_env,
set_helper_precache_enabled,
)
from utils.model_ttl_settings import (
MAX_MODEL_IDLE_TTL_SECONDS,
MIN_MODEL_IDLE_TTL_SECONDS,
default_model_idle_ttl_seconds,
get_model_idle_ttl_seconds,
set_model_idle_ttl_seconds,
)
router = APIRouter()
logger = get_logger(__name__)
class UploadLimitPayload(BaseModel):
max_upload_size_mb: int = Field(..., ge = MIN_UPLOAD_LIMIT_MB, le = MAX_UPLOAD_LIMIT_MB)
class UploadLimitResponse(BaseModel):
max_upload_size_mb: int
max_upload_size_bytes: int
max_upload_size_label: str
default_upload_size_mb: int
min_upload_size_mb: int = MIN_UPLOAD_LIMIT_MB
max_allowed_upload_size_mb: int = MAX_UPLOAD_LIMIT_MB
class HelperPrecachePayload(BaseModel):
enabled: bool
class HelperPrecacheResponse(BaseModel):
enabled: bool
default_enabled: bool = DEFAULT_HELPER_PRECACHE_ENABLED
disabled_by_env: bool
def _upload_limit_response(limit_mb: int) -> UploadLimitResponse:
return UploadLimitResponse(
max_upload_size_mb = limit_mb,
max_upload_size_bytes = upload_limit_bytes(limit_mb),
max_upload_size_label = upload_limit_label(limit_mb),
default_upload_size_mb = default_upload_limit_mb(),
)
def _helper_precache_response(enabled: bool | None = None) -> HelperPrecacheResponse:
return HelperPrecacheResponse(
enabled = get_helper_precache_enabled() if enabled is None else enabled,
disabled_by_env = helper_model_disabled_by_env(),
)
@router.get("/upload-limit", response_model = UploadLimitResponse)
def get_upload_limit(current_subject: str = Depends(get_current_subject)) -> UploadLimitResponse:
return _upload_limit_response(get_upload_limit_mb())
@router.put("/upload-limit", response_model = UploadLimitResponse)
def update_upload_limit(
payload: UploadLimitPayload, current_subject: str = Depends(get_current_subject)
) -> UploadLimitResponse:
try:
limit_mb = set_upload_limit_mb(payload.max_upload_size_mb)
except ValueError as exc:
raise log_and_http_error(
exc,
400,
safe_error_detail(exc, fallback = "Invalid upload limit."),
event = "settings.update_upload_limit_failed",
log = logger,
) from exc
return _upload_limit_response(limit_mb)
@router.get("/helper-precache", response_model = HelperPrecacheResponse)
def get_helper_precache(
current_subject: str = Depends(get_current_subject),
) -> HelperPrecacheResponse:
return _helper_precache_response()
@router.put("/helper-precache", response_model = HelperPrecacheResponse)
def update_helper_precache(
payload: HelperPrecachePayload, current_subject: str = Depends(get_current_subject)
) -> HelperPrecacheResponse:
try:
enabled = set_helper_precache_enabled(payload.enabled)
except ValueError as exc:
raise log_and_http_error(
exc,
400,
safe_error_detail(exc, fallback = "Invalid Helper LLM pre-cache setting."),
event = "settings.update_helper_precache_failed",
log = logger,
) from exc
return _helper_precache_response(enabled)
def _is_bundled_avatar_url(value: str) -> bool:
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc:
return False
path = unquote(parsed.path).lstrip("/")
if ".." in path.split("/"):
return False
marker = "Sloth emojis/"
if marker not in path:
return False
return path[path.index(marker) :].lower().endswith(".png")
class PersonalizationProfile(BaseModel):
model_config = ConfigDict(extra = "ignore")
displayName: str = Field("", max_length = 200)
nickname: str = Field("", max_length = 200)
avatarDataUrl: Optional[str] = Field(None, max_length = MAX_AVATAR_DATA_URL_BYTES)
avatarShape: Literal["circle", "rounded"] = "circle"
@field_validator("avatarDataUrl")
@classmethod
def _validate_avatar(cls, value: Optional[str]) -> Optional[str]:
if not value:
return value
if not value.startswith("data:image/") and not _is_bundled_avatar_url(value):
raise ValueError("avatarDataUrl must be an image data URL or bundled avatar.")
return value
class PersonalizationAppearance(BaseModel):
model_config = ConfigDict(extra = "ignore")
theme: Literal["light", "dark", "system"] = "system"
language: Optional[str] = Field(None, max_length = 20)
class PersonalizationPayload(BaseModel):
model_config = ConfigDict(extra = "ignore")
version: int = PERSONALIZATION_VERSION
profile: PersonalizationProfile = Field(default_factory = PersonalizationProfile)
appearance: PersonalizationAppearance = Field(default_factory = PersonalizationAppearance)
class PersonalizationResponse(PersonalizationPayload):
saved: bool = False
@router.get("/personalization", response_model = PersonalizationResponse)
def get_personalization_settings(
current_subject: str = Depends(get_current_subject),
) -> PersonalizationResponse:
stored = get_personalization()
response = PersonalizationResponse.model_validate(stored or {})
response.saved = bool(stored)
return response
@router.put("/personalization", response_model = PersonalizationPayload)
def update_personalization_settings(
payload: PersonalizationPayload, current_subject: str = Depends(get_current_subject)
) -> PersonalizationPayload:
try:
set_personalization(payload.model_dump())
except ValueError as exc:
raise log_and_http_error(
exc,
400,
safe_error_detail(exc, fallback = "Invalid personalization settings."),
event = "settings.update_personalization_failed",
log = logger,
) from exc
return payload
class ModelIdleTtlPayload(BaseModel):
idle_ttl_seconds: int = Field(..., ge = MIN_MODEL_IDLE_TTL_SECONDS, le = MAX_MODEL_IDLE_TTL_SECONDS)
class ModelIdleTtlResponse(BaseModel):
idle_ttl_seconds: int
default_idle_ttl_seconds: int
min_idle_ttl_seconds: int = MIN_MODEL_IDLE_TTL_SECONDS
max_idle_ttl_seconds: int = MAX_MODEL_IDLE_TTL_SECONDS
enabled: bool
loaded_model_idle_seconds: float | None = None
evicts_in_seconds: float | None = None
def _model_idle_ttl_response(ttl: int) -> ModelIdleTtlResponse:
idle: float | None = None
evicts_in: float | None = None
try:
from routes.inference import get_llama_cpp_backend
backend = get_llama_cpp_backend()
idle = backend.idle_seconds
if ttl > 0 and idle is not None:
evicts_in = max(0.0, ttl - idle)
except Exception:
pass
return ModelIdleTtlResponse(
idle_ttl_seconds = ttl,
default_idle_ttl_seconds = default_model_idle_ttl_seconds(),
enabled = ttl > 0,
loaded_model_idle_seconds = idle,
evicts_in_seconds = evicts_in,
)
@router.get("/model-ttl", response_model = ModelIdleTtlResponse)
def get_model_ttl(current_subject: str = Depends(get_current_subject)) -> ModelIdleTtlResponse:
return _model_idle_ttl_response(get_model_idle_ttl_seconds())
@router.put("/model-ttl", response_model = ModelIdleTtlResponse)
def update_model_ttl(
payload: ModelIdleTtlPayload, current_subject: str = Depends(get_current_subject)
) -> ModelIdleTtlResponse:
try:
ttl = set_model_idle_ttl_seconds(payload.idle_ttl_seconds)
except ValueError as exc:
raise log_and_http_error(
exc,
400,
safe_error_detail(exc, fallback = "Invalid model idle TTL."),
event = "settings.update_model_ttl_failed",
log = logger,
) from exc
return _model_idle_ttl_response(ttl)