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.
263 lines
8.6 KiB
Python
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)
|