* Studio: hide infra models from the hub cached inventory The hub inventory scans behind /api/hub/cached-gguf and /api/hub/cached-models returned the llama.cpp install validation probe (ggml-org/models) and the RAG embedder (unsloth/bge-small-en-v1.5[-GGUF]) as on-device models. Share the hidden-model check from routes/models.py via utils/models/hidden_models.py and apply it in both scans. A GGUF infra repo stays visible when the user explicitly downloaded a variant through the Hub, since variant manifests only exist for user-initiated downloads. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make On Device trust the hub inventory, match repo ids exactly, lighten the hidden-model import Follow-up on the hub cached-inventory hidden-model change, addressing the review. On Device now trusts the Hub inventory API for cached rows. The backend already hides the RAG embedder and the llama.cpp probe and re-includes a GGUF infra repo once the user downloads a variant through the Hub, but the frontend was re-hiding it by repo id, so the user-downloaded variant never appeared in the On Device list or the count. isVisibleInventoryRow now short-circuits cached rows (kind === "cache") to visible and keeps client-side needle hiding only for local filesystem rows and Discover. is_hidden_model matches Hub repo ids exactly (case-insensitive) against the probe plus the effective embedder and its GGUF companion, instead of substring matching the configured-embedder basename. A custom embedder with a generic basename like org/model no longer hides unrelated cached repos such as user/model-chat or org/model-instruct. The probe filename and local-path embedders keep exact matching. The helper moves to utils/hidden_models.py and is imported at module scope in the hub cache scanner, so it no longer pulls in utils/models/__init__ (the eager model-config/checkpoint stack) and a broken import fails at startup instead of being swallowed per-repo and silently emptying the inventory. routes.models keeps the _is_hidden_model and _safe_resolve aliases and drops the unused _HF_REPO_ID_RE re-export that was failing source lint. Tests: exact repo-id matching with a custom embedder, the cached-models scan keeping an unrelated repo, and a clean-interpreter check that the helper imports without the model-config stack. * Studio: match the llama.cpp probe filename on both path separators The hidden-model check compared the probe's on-disk filename with Path(value).name, which on a POSIX interpreter does not split a Windows-style path ("...\stories260K.gguf") and would let the probe through. Split on both separators so the probe is matched regardless of which OS produced the path, matching the tolerance of the previous substring check. Adds a Windows-path assertion to the probe test. * Studio: harden hidden infra model handling * Fix hidden cache row confirmation * Fix hidden local rows and confirmed hint merges * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle snapshot-configured hidden models * Hide basename-only default embedders * Fix dynamic embedder inventory filtering * Studio: hide the configured RAG embedder from Discover and feed rows --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
813 lines
32 KiB
Python
813 lines
32 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
|
|
|
|
import re
|
|
from typing import Literal, Optional
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from auth.authentication import get_current_subject
|
|
from auth.storage import rotate_preview_link_secret
|
|
from core.rag.config import default_gguf_repo, effective_gguf_repo
|
|
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.coding_agents import CODING_AGENTS, detect_installed_coding_agents
|
|
from utils.openai_auto_switch_settings import (
|
|
DEFAULT_AUTO_UNLOAD_IDLE_SECONDS,
|
|
DEFAULT_OPENAI_AUTO_SWITCH_ENABLED,
|
|
get_auto_unload_idle_seconds,
|
|
get_model_overrides,
|
|
get_openai_auto_switch_enabled,
|
|
get_stored_auto_unload_idle_seconds,
|
|
set_model_override,
|
|
set_openai_auto_switch,
|
|
)
|
|
from utils.preview_sharing_settings import (
|
|
DEFAULT_PREVIEW_SHARING_ENABLED,
|
|
get_preview_sharing_enabled,
|
|
set_preview_sharing_enabled,
|
|
)
|
|
from utils.embedding_model_settings import (
|
|
MAX_EMBEDDING_MODEL_LENGTH,
|
|
default_embedding_model,
|
|
get_rag_embedding_model,
|
|
get_stored_embedding_model,
|
|
reset_rag_embedding_model,
|
|
set_rag_embedding_model,
|
|
validate_embedding_model,
|
|
)
|
|
|
|
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
|
|
|
|
|
|
class OpenAIAutoSwitchPayload(BaseModel):
|
|
enabled: bool
|
|
auto_unload_idle_seconds: int = Field(default = DEFAULT_AUTO_UNLOAD_IDLE_SECONDS, ge = 0)
|
|
|
|
|
|
class OpenAIAutoSwitchResponse(BaseModel):
|
|
enabled: bool
|
|
auto_unload_idle_seconds: int
|
|
default_enabled: bool = DEFAULT_OPENAI_AUTO_SWITCH_ENABLED
|
|
# True when the idle-unload loop will actually unload (effective TTL > 0). With
|
|
# UNSLOTH_MODEL_IDLE_TTL set and nothing stored, this is true even while enabled
|
|
# is false, so the UI can show idle-unload as active instead of "needs enable".
|
|
idle_unload_active: bool = False
|
|
|
|
|
|
class ModelOverridePayload(BaseModel):
|
|
model_id: str = Field(..., min_length = 1)
|
|
llama_extra_args: list[str] = Field(default_factory = list)
|
|
# ge=1: 0 is not a valid sequence length, and the setter drops a falsy value,
|
|
# so reject it at the boundary instead of accepting then silently discarding it.
|
|
max_seq_length: Optional[int] = Field(default = None, ge = 1, le = 1048576)
|
|
|
|
|
|
class ModelOverridesResponse(BaseModel):
|
|
overrides: dict[str, dict]
|
|
|
|
|
|
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)
|
|
|
|
|
|
class CodingAgentsResponse(BaseModel):
|
|
# All agents `unsloth start` supports, in the CLI's declared order.
|
|
agents: tuple[str, ...] = CODING_AGENTS
|
|
# Subset of `agents` whose CLI binary was found on PATH; the frontend uses
|
|
# this to default the API-keys panel to a command the user can run as-is.
|
|
detected: list[str]
|
|
|
|
|
|
@router.get("/coding-agents", response_model = CodingAgentsResponse)
|
|
def get_coding_agents(current_subject: str = Depends(get_current_subject)) -> CodingAgentsResponse:
|
|
return CodingAgentsResponse(detected = detect_installed_coding_agents())
|
|
|
|
|
|
@router.get("/openai-auto-switch", response_model = OpenAIAutoSwitchResponse)
|
|
def get_openai_auto_switch(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> OpenAIAutoSwitchResponse:
|
|
return OpenAIAutoSwitchResponse(
|
|
enabled = get_openai_auto_switch_enabled(),
|
|
auto_unload_idle_seconds = get_stored_auto_unload_idle_seconds(),
|
|
idle_unload_active = get_auto_unload_idle_seconds() > 0,
|
|
)
|
|
|
|
|
|
@router.put("/openai-auto-switch", response_model = OpenAIAutoSwitchResponse)
|
|
def update_openai_auto_switch(
|
|
payload: OpenAIAutoSwitchPayload, current_subject: str = Depends(get_current_subject)
|
|
) -> OpenAIAutoSwitchResponse:
|
|
try:
|
|
enabled, idle_seconds = set_openai_auto_switch(
|
|
payload.enabled, payload.auto_unload_idle_seconds
|
|
)
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
safe_error_detail(exc, fallback = "Invalid OpenAI auto-switch setting."),
|
|
event = "settings.update_openai_auto_switch_failed",
|
|
log = logger,
|
|
) from exc
|
|
return OpenAIAutoSwitchResponse(
|
|
enabled = enabled,
|
|
auto_unload_idle_seconds = idle_seconds,
|
|
idle_unload_active = get_auto_unload_idle_seconds() > 0,
|
|
)
|
|
|
|
|
|
@router.get("/openai-auto-switch/overrides", response_model = ModelOverridesResponse)
|
|
def get_openai_auto_switch_overrides(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> ModelOverridesResponse:
|
|
return ModelOverridesResponse(overrides = get_model_overrides())
|
|
|
|
|
|
@router.put("/openai-auto-switch/overrides", response_model = ModelOverridesResponse)
|
|
def update_openai_auto_switch_override(
|
|
payload: ModelOverridePayload, current_subject: str = Depends(get_current_subject)
|
|
) -> ModelOverridesResponse:
|
|
from core.inference.llama_server_args import validate_extra_args
|
|
try:
|
|
extra_args = validate_extra_args(payload.llama_extra_args)
|
|
set_model_override(
|
|
payload.model_id,
|
|
llama_extra_args = extra_args,
|
|
max_seq_length = payload.max_seq_length,
|
|
)
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
safe_error_detail(exc, fallback = "Invalid model launch override."),
|
|
event = "settings.update_model_override_failed",
|
|
log = logger,
|
|
) from exc
|
|
return ModelOverridesResponse(overrides = get_model_overrides())
|
|
|
|
|
|
class EmbeddingModelPayload(BaseModel):
|
|
embedding_model: str = Field(..., min_length = 1, max_length = MAX_EMBEDDING_MODEL_LENGTH)
|
|
# Token for gated/private repos during verification (not stored).
|
|
hf_token: Optional[str] = Field(default = None, max_length = 512)
|
|
# Skip HF verification (offline installs, local paths HF can't see).
|
|
force: bool = False
|
|
|
|
|
|
class EmbeddingModelResponse(BaseModel):
|
|
embedding_model: str
|
|
embedding_gguf_repo: str
|
|
default_embedding_model: str
|
|
default_embedding_gguf_repo: str
|
|
is_custom: bool
|
|
|
|
|
|
def _embedding_model_response() -> EmbeddingModelResponse:
|
|
return EmbeddingModelResponse(
|
|
embedding_model = get_rag_embedding_model(),
|
|
embedding_gguf_repo = effective_gguf_repo(),
|
|
default_embedding_model = default_embedding_model(),
|
|
default_embedding_gguf_repo = default_gguf_repo(),
|
|
is_custom = get_stored_embedding_model() is not None,
|
|
)
|
|
|
|
|
|
def _ambient_hf_token() -> Optional[str]:
|
|
"""The HF token the loader would use (HF_TOKEN env or the cached login), so a gated
|
|
repo is scanned rather than failing open. None if unavailable."""
|
|
try:
|
|
from huggingface_hub import get_token
|
|
return get_token()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _llama_backend_active() -> bool:
|
|
"""True when this install actually embeds via the llama-server (GGUF) backend.
|
|
|
|
Delegates to the embeddings module so a runtime fallback from
|
|
sentence-transformers to llama-server (after a torch/CUDA load or encode
|
|
failure) is honored: in that state the process loads only inert GGUF, so the
|
|
ST pickle gate below must not hard-block a repo whose GGUF companion is clean.
|
|
Before any backend is built this still reflects the resolver."""
|
|
from core.rag import embeddings
|
|
try:
|
|
return embeddings.active_backend_is_llama()
|
|
except Exception: # noqa: BLE001 - backend probe must never block saving
|
|
return False
|
|
|
|
|
|
def _resolves_as_local_gguf(model: str) -> bool:
|
|
"""True when ``model`` is a local .gguf file or a directory holding one, so
|
|
a save on the llama-server backend needs no HF verification (the artifact
|
|
itself is the proof)."""
|
|
from core.rag.embed_llama_server import LlamaServerBackend
|
|
try:
|
|
return LlamaServerBackend._resolve_local_gguf(model) is not None
|
|
except Exception: # noqa: BLE001 - dir without .gguf, filesystem oddity
|
|
return False
|
|
|
|
|
|
def _local_gguf_backend_error(model: str) -> str | None:
|
|
"""409 detail when ``model`` is a local dir without a .gguf but this install
|
|
embeds via llama-server (macOS/CPU default), which needs one. A
|
|
sentence-transformers-only folder would verify fine yet fail at first index.
|
|
None when not applicable. ``force`` skips this check like HF verification."""
|
|
from pathlib import Path
|
|
|
|
if not Path(model).expanduser().is_dir():
|
|
return None
|
|
from core.rag.embed_llama_server import LlamaServerBackend
|
|
|
|
if not _llama_backend_active():
|
|
return None
|
|
try:
|
|
LlamaServerBackend._resolve_local_gguf(model)
|
|
return None
|
|
except RuntimeError:
|
|
return (
|
|
f"{model!r} contains no .gguf file, but this install embeds with the "
|
|
"llama-server backend which requires one. Add a GGUF file to the "
|
|
"folder or use a Hugging Face repo."
|
|
)
|
|
except Exception: # noqa: BLE001 - filesystem oddity: don't block saving
|
|
return None
|
|
|
|
|
|
def _hf_gguf_backend_error(model: str, hf_token: Optional[str]) -> str | None:
|
|
"""409 detail when the llama-server backend would find no .gguf for an HF
|
|
repo: neither the derived companion repo nor the repo itself has one. Saves
|
|
that verify as embedding models would otherwise fail at first index.
|
|
None when not applicable; ``force`` skips this like HF verification."""
|
|
from pathlib import Path
|
|
|
|
if Path(model).expanduser().exists():
|
|
return None # local paths are handled by the local checks
|
|
if not _llama_backend_active():
|
|
return None
|
|
from core.rag import config as rag_config
|
|
|
|
candidates = [model] if rag_config._names_gguf(model) else [f"{model}-GGUF", model]
|
|
try:
|
|
from huggingface_hub import list_repo_files
|
|
except Exception: # noqa: BLE001 - hub client unavailable: don't block saving
|
|
return None
|
|
for candidate in candidates:
|
|
try:
|
|
files = list_repo_files(candidate, token = hf_token)
|
|
except Exception: # noqa: BLE001 - missing/gated repo: try next candidate
|
|
continue
|
|
if any(f.lower().endswith(".gguf") and "mmproj" not in f.lower() for f in files):
|
|
return None
|
|
checked = " or ".join(repr(c) for c in candidates)
|
|
return (
|
|
f"No GGUF weights found in {checked}, but this install embeds with the "
|
|
"llama-server backend which requires them. Pick a model with a GGUF "
|
|
"companion repo or GGUF files in the repo itself."
|
|
)
|
|
|
|
|
|
@router.get("/embedding-model", response_model = EmbeddingModelResponse)
|
|
def get_embedding_model(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> EmbeddingModelResponse:
|
|
return _embedding_model_response()
|
|
|
|
|
|
@router.put("/embedding-model", response_model = EmbeddingModelResponse)
|
|
def update_embedding_model(
|
|
payload: EmbeddingModelPayload, current_subject: str = Depends(get_current_subject)
|
|
) -> EmbeddingModelResponse:
|
|
"""Set the RAG embedding model. Unless ``force`` is set, the repo is verified
|
|
to be an embedding model via HF metadata; an unverifiable model (wrong type,
|
|
typo, gated repo, or no network) returns 409 so the UI can offer "save anyway".
|
|
A repo flagged unsafe by HF's security scan returns 403 instead: a hard block
|
|
that ``force`` cannot bypass, so the UI must not offer "save anyway".
|
|
Documents indexed under the previous model must be re-uploaded."""
|
|
from utils.models import is_embedding_model
|
|
|
|
try:
|
|
model = validate_embedding_model(payload.embedding_model)
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
safe_error_detail(exc, fallback = "Invalid embedding model."),
|
|
event = "settings.update_embedding_model_failed",
|
|
log = logger,
|
|
) from exc
|
|
hf_token = (payload.hf_token or "").strip() or None
|
|
# The env/default model needs no verification; saving it is a no-op override.
|
|
# A local GGUF on the llama-server backend is accepted as-is: it is exactly
|
|
# what the backend loads, and HF metadata cannot verify a local path.
|
|
is_local_gguf = _llama_backend_active() and _resolves_as_local_gguf(model)
|
|
# The pickle gate only matters for the sentence-transformers backend, which is what
|
|
# deserializes pickles. On the llama-server backend the embedder loads GGUF files
|
|
# (inert) from effective_gguf_repo(), so scanning the ST repo's pickle here would
|
|
# wrongly reject a custom repo whose GGUF companion is clean; the GGUF availability
|
|
# checks below cover that path instead.
|
|
scan_st_pickle = (
|
|
model != default_embedding_model() and not is_local_gguf and not _llama_backend_active()
|
|
)
|
|
if scan_st_pickle:
|
|
# Malware/pickle gate before we persist a repo the embedder later loads with
|
|
# SentenceTransformer. Runs even under force (force only skips the is-embedding
|
|
# type check for offline/local repos HF cannot verify); local paths and
|
|
# unreachable scans fail open inside evaluate_file_security.
|
|
from utils.security import evaluate_file_security, security_load_subdirs
|
|
from core.rag.embeddings import _st_module_subdirs
|
|
|
|
# Fall back to the loader's own token so a gated/private repo is actually scanned
|
|
# (a token-less scan fails open for exactly the repo that would still load).
|
|
scan_token = hf_token or _ambient_hf_token()
|
|
# Include the ST module dirs (0_Transformer/) so a flagged pickle directly under
|
|
# one blocks instead of passing as an unreferenced nested shard.
|
|
load_subdirs = tuple(
|
|
dict.fromkeys(
|
|
(
|
|
*security_load_subdirs(model, scan_token),
|
|
*_st_module_subdirs(model, scan_token),
|
|
)
|
|
)
|
|
)
|
|
if evaluate_file_security(model, hf_token = scan_token, load_subdirs = load_subdirs).blocked:
|
|
# 403, not 409: the client routes every 409 into the forceable "save anyway"
|
|
# flow, but this block is a hard, non-forceable security refusal.
|
|
raise HTTPException(
|
|
status_code = 403,
|
|
detail = (
|
|
f"{model!r} is flagged as unsafe by Hugging Face's security scan and "
|
|
"cannot be used as the embedding model."
|
|
),
|
|
)
|
|
if model != default_embedding_model() and not payload.force and not is_local_gguf:
|
|
from core.rag import config as rag_config
|
|
|
|
# A GGUF-named repo on the llama-server backend is loaded from its .gguf
|
|
# files, which rarely carry sentence-transformers metadata; verify the
|
|
# GGUF is available (below) rather than the ST embedding-metadata gate,
|
|
# which would wrongly 409 a valid online GGUF embedder.
|
|
gguf_named = _llama_backend_active() and rag_config._names_gguf(model)
|
|
if not gguf_named and not is_embedding_model(model, hf_token = hf_token):
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
f"Could not verify {model!r} as an embedding model on "
|
|
"Hugging Face (it may be the wrong model type, gated, or "
|
|
"you may be offline)."
|
|
),
|
|
)
|
|
gguf_error = _local_gguf_backend_error(model) or _hf_gguf_backend_error(model, hf_token)
|
|
if gguf_error:
|
|
raise HTTPException(status_code = 409, detail = gguf_error)
|
|
set_rag_embedding_model(model)
|
|
logger.info(
|
|
"settings.embedding_model_updated subject=%s model=%s forced=%s",
|
|
current_subject,
|
|
model,
|
|
payload.force,
|
|
)
|
|
return _embedding_model_response()
|
|
|
|
|
|
@router.delete("/embedding-model", response_model = EmbeddingModelResponse)
|
|
def reset_embedding_model(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> EmbeddingModelResponse:
|
|
"""Clear the override, returning to the env/default model."""
|
|
reset_rag_embedding_model()
|
|
logger.info("settings.embedding_model_reset subject=%s", current_subject)
|
|
return _embedding_model_response()
|
|
|
|
|
|
class PreviewLinkRotateResponse(BaseModel):
|
|
rotated: bool = True
|
|
|
|
|
|
@router.post("/preview-links/rotate", response_model = PreviewLinkRotateResponse)
|
|
def rotate_preview_links(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> PreviewLinkRotateResponse:
|
|
"""Rotate the preview-link signing secret, revoking every previously shared `/p` link."""
|
|
rotate_preview_link_secret()
|
|
logger.info("settings.preview_links_rotated subject=%s", current_subject)
|
|
return PreviewLinkRotateResponse(rotated = True)
|
|
|
|
|
|
class PreviewSharingPayload(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class PreviewSharingResponse(BaseModel):
|
|
enabled: bool
|
|
default_enabled: bool = DEFAULT_PREVIEW_SHARING_ENABLED
|
|
|
|
|
|
@router.get("/preview-sharing", response_model = PreviewSharingResponse)
|
|
def get_preview_sharing(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> PreviewSharingResponse:
|
|
return PreviewSharingResponse(enabled = get_preview_sharing_enabled())
|
|
|
|
|
|
@router.put("/preview-sharing", response_model = PreviewSharingResponse)
|
|
def update_preview_sharing(
|
|
payload: PreviewSharingPayload, current_subject: str = Depends(get_current_subject)
|
|
) -> PreviewSharingResponse:
|
|
"""Enable/disable the public `/p` preview surface. When off, links 404 even with a token."""
|
|
try:
|
|
enabled = set_preview_sharing_enabled(payload.enabled)
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
safe_error_detail(exc, fallback = "Invalid preview sharing setting."),
|
|
event = "settings.update_preview_sharing_failed",
|
|
log = logger,
|
|
) from exc
|
|
logger.info("settings.preview_sharing_updated subject=%s enabled=%s", current_subject, enabled)
|
|
return PreviewSharingResponse(enabled = 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"
|
|
showGreetingSloth: bool = True
|
|
|
|
@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 PersonalizationCustomColors(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
accent: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$")
|
|
background: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$")
|
|
foreground: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$")
|
|
|
|
|
|
class PersonalizationCustomColorModes(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
light: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors)
|
|
dark: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors)
|
|
|
|
|
|
MAX_IMPORTED_FONTS = 3
|
|
# ~1.5 MB font file as base64; matches MAX_IMPORTED_FONT_DATA_URL_LENGTH in
|
|
# the frontend appearance-custom-store.
|
|
MAX_FONT_DATA_URL_LENGTH = 2_200_000
|
|
# Aggregate cap across all imported fonts; matches
|
|
# MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH in the frontend so a synced payload
|
|
# always fits the browser's localStorage quota.
|
|
MAX_TOTAL_FONT_DATA_URL_LENGTH = 4_400_000
|
|
|
|
# Characters that could terminate a CSS declaration, escape the quoted
|
|
# font-family value (backslash), or smuggle extra fallbacks/comments (comma,
|
|
# slash) if a stored name ever reached a stylesheet. The server is the
|
|
# authoritative gate; the frontend strips the same set before use.
|
|
_FONT_NAME_FORBIDDEN = set(";{}()<>\"'\\/,`")
|
|
|
|
|
|
def _check_font_name(value: str) -> str:
|
|
if any(c in _FONT_NAME_FORBIDDEN or ord(c) < 0x20 for c in value):
|
|
raise ValueError("Font name contains invalid characters.")
|
|
return value
|
|
|
|
|
|
# Matches FONT_DATA_URL_PATTERN in the frontend appearance-custom-store.
|
|
_FONT_DATA_URL_PATTERN = re.compile(
|
|
r"^data:(?:font/(?:woff2?|ttf|otf|sfnt)"
|
|
r"|application/(?:octet-stream|x-font-\w+|font-\w+));base64,[A-Za-z0-9+/=]+$"
|
|
)
|
|
|
|
|
|
class PersonalizationImportedFont(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
name: str = Field(..., min_length = 1, max_length = 100)
|
|
dataUrl: str = Field(..., max_length = MAX_FONT_DATA_URL_LENGTH)
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def _validate_font_name(cls, value: str) -> str:
|
|
return _check_font_name(value)
|
|
|
|
@field_validator("dataUrl")
|
|
@classmethod
|
|
def _validate_font_data_url(cls, value: str) -> str:
|
|
# fullmatch, not match: re's ``$`` also matches just before a trailing
|
|
# newline, so ``match`` would accept "data:font/woff2;base64,AAAA\n",
|
|
# which the frontend's JS pattern (``$`` = end of string) rejects.
|
|
if not _FONT_DATA_URL_PATTERN.fullmatch(value):
|
|
raise ValueError("dataUrl must be a base64 font data URL.")
|
|
return value
|
|
|
|
|
|
# Optional user-menu items; the boolean is each id's default visibility.
|
|
# Settings-tab shortcuts ship hidden.
|
|
SIDEBAR_MENU_ITEM_DEFAULTS = {
|
|
"api": True,
|
|
"darkMode": True,
|
|
"guidedTour": True,
|
|
"profile": False,
|
|
"appearance": False,
|
|
"resources": False,
|
|
"chat": False,
|
|
"connections": False,
|
|
}
|
|
|
|
# The sidebarMenu validator below dedupes ids and re-fills any missing ones, so
|
|
# the stored list is always exactly one entry per id. Cap the *incoming* list at
|
|
# a generous multiple rather than len(defaults): a stale or duplicated payload
|
|
# (more items than distinct ids) must reach the validator so it can normalize,
|
|
# instead of being rejected by the length constraint before dedupe runs. A
|
|
# pathologically long list is still refused.
|
|
MAX_SIDEBAR_MENU_INPUT_ITEMS = 4 * len(SIDEBAR_MENU_ITEM_DEFAULTS)
|
|
|
|
|
|
class PersonalizationSidebarMenuItem(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
id: Literal[
|
|
"api",
|
|
"darkMode",
|
|
"guidedTour",
|
|
"profile",
|
|
"appearance",
|
|
"resources",
|
|
"chat",
|
|
"connections",
|
|
]
|
|
visible: bool = True
|
|
|
|
|
|
def _default_sidebar_menu() -> "list[PersonalizationSidebarMenuItem]":
|
|
return [
|
|
PersonalizationSidebarMenuItem(id = item_id, visible = visible)
|
|
for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items()
|
|
]
|
|
|
|
|
|
class PersonalizationCustomization(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
colors: PersonalizationCustomColorModes = Field(default_factory = PersonalizationCustomColorModes)
|
|
uiFont: Optional[str] = Field(None, max_length = 200)
|
|
headingFont: Optional[str] = Field(None, max_length = 200)
|
|
chatFont: Optional[str] = Field(None, max_length = 200)
|
|
codeFont: Optional[str] = Field(None, max_length = 200)
|
|
importedFonts: list[PersonalizationImportedFont] = Field(
|
|
default_factory = list, max_length = MAX_IMPORTED_FONTS
|
|
)
|
|
|
|
@field_validator("importedFonts")
|
|
@classmethod
|
|
def _validate_total_font_size(
|
|
cls, value: list[PersonalizationImportedFont]
|
|
) -> list[PersonalizationImportedFont]:
|
|
if sum(len(f.dataUrl) for f in value) > MAX_TOTAL_FONT_DATA_URL_LENGTH:
|
|
raise ValueError("Imported fonts exceed the total size limit.")
|
|
return value
|
|
|
|
@field_validator("uiFont", "headingFont", "chatFont", "codeFont")
|
|
@classmethod
|
|
def _validate_selected_fonts(cls, value: Optional[str]) -> Optional[str]:
|
|
# Selected font names reach CSS the same way imported names do.
|
|
return value if value is None else _check_font_name(value)
|
|
|
|
uiFontSize: Optional[int] = Field(None, ge = 12, le = 20)
|
|
codeFontSize: Optional[int] = Field(None, ge = 10, le = 20)
|
|
contrast: int = Field(50, ge = 0, le = 100)
|
|
pointerCursors: bool = False
|
|
reduceMotion: Literal["system", "on", "off"] = "system"
|
|
fontSmoothing: bool = True
|
|
sidebarMenu: list[PersonalizationSidebarMenuItem] = Field(
|
|
default_factory = _default_sidebar_menu,
|
|
max_length = MAX_SIDEBAR_MENU_INPUT_ITEMS,
|
|
)
|
|
|
|
@field_validator("sidebarMenu")
|
|
@classmethod
|
|
def _validate_sidebar_menu(
|
|
cls, value: list[PersonalizationSidebarMenuItem]
|
|
) -> list[PersonalizationSidebarMenuItem]:
|
|
# Drop duplicate ids (keep the first) and re-append any missing ids so
|
|
# the stored list always covers every optional menu item exactly once.
|
|
seen: set[str] = set()
|
|
items = [item for item in value if not (item.id in seen or seen.add(item.id))]
|
|
for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items():
|
|
if item_id not in seen:
|
|
items.append(PersonalizationSidebarMenuItem(id = item_id, visible = visible))
|
|
return items
|
|
|
|
|
|
class PersonalizationAppearance(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
theme: Literal["light", "dark", "system"] = "system"
|
|
palette: Literal["standard", "classic", "minimal"] = "standard"
|
|
language: Optional[str] = Field(None, max_length = 20)
|
|
customization: PersonalizationCustomization = Field(
|
|
default_factory = PersonalizationCustomization
|
|
)
|
|
|
|
|
|
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
|
|
# False when the stored record predates a field, so the client keeps local
|
|
# overrides instead of treating a server-filled default as an explicit value.
|
|
customizationSaved: bool = False
|
|
paletteSaved: bool = False
|
|
greetingSlothSaved: 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)
|
|
appearance = stored.get("appearance") if isinstance(stored, dict) else None
|
|
profile = stored.get("profile") if isinstance(stored, dict) else None
|
|
response.customizationSaved = isinstance(appearance, dict) and "customization" in appearance
|
|
response.paletteSaved = isinstance(appearance, dict) and "palette" in appearance
|
|
response.greetingSlothSaved = isinstance(profile, dict) and "showGreetingSloth" in profile
|
|
return response
|
|
|
|
|
|
def _merge_personalization(base: dict, overlay: dict) -> dict:
|
|
# Recursively overlay only the request's set fields onto the stored record,
|
|
# so a stale client that omits newer keys (palette, customization) does not
|
|
# materialize their defaults and defeat the *Saved legacy detection.
|
|
merged = dict(base)
|
|
for key, value in overlay.items():
|
|
existing = merged.get(key)
|
|
if isinstance(value, dict) and isinstance(existing, dict):
|
|
merged[key] = _merge_personalization(existing, value)
|
|
else:
|
|
merged[key] = value
|
|
return merged
|
|
|
|
|
|
@router.put("/personalization", response_model = PersonalizationPayload)
|
|
def update_personalization_settings(
|
|
payload: PersonalizationPayload, current_subject: str = Depends(get_current_subject)
|
|
) -> PersonalizationPayload:
|
|
try:
|
|
# exclude_unset so absent fields are not persisted as defaults; merge so
|
|
# fields the request omits keep whatever the record already stored.
|
|
incoming = payload.model_dump(exclude_unset = True)
|
|
merged = _merge_personalization(get_personalization(), incoming)
|
|
set_personalization(merged)
|
|
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 the stored record, not the defaults-filled request, so the response
|
|
# matches storage (and the next GET) for fields the client omitted.
|
|
return PersonalizationPayload.model_validate(merged)
|