e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
609 lines
23 KiB
Python
609 lines
23 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, 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 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
|
|
default_embedding_model: str
|
|
is_custom: bool
|
|
|
|
|
|
def _embedding_model_response() -> EmbeddingModelResponse:
|
|
return EmbeddingModelResponse(
|
|
embedding_model = get_rag_embedding_model(),
|
|
default_embedding_model = default_embedding_model(),
|
|
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"
|
|
|
|
@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
|