* feat: detect installed coding agent CLIs in Studio settings
The API-keys panel only ever showed the "claude" flavor of the
`unsloth start` command, so anyone using Codex, OpenCode, OpenClaw,
Hermes, or Pi had to manually rewrite the copied command by hand.
Add a backend check that looks for each agent's CLI binary on PATH
(shutil.which, mirroring the pattern already used elsewhere in
studio/backend/utils) and expose it as GET /api/settings/coding-agents.
The API-keys panel now renders a picker for all six supported agents,
marks the ones it finds installed, and defaults to one of those instead
of always falling back to claude.
Includes unit tests for the detection helper.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* address review feedback on coding-agent detection
Three fixes from PR review:
- detect_installed_coding_agents now treats a PATH lookup failure as
"not installed" instead of letting it bubble up and break the
settings endpoint; added a regression test for it.
- CodingAgentsResponse.agents is now typed as an immutable tuple
instead of a list built from one, matching CODING_AGENTS itself.
- Fixed a race in the API-keys panel: picking an agent while the
installed-CLI check is still in flight could get silently overwritten
once that check resolved. A ref now tracks whether the user has made
a manual choice, so the auto-detected default only applies before
that happens.
* Address Codex feedback: GGUF gating and remote-detection scope
- codex refuses to launch against a non-GGUF (transformers-backed) model
(unsloth_cli's _require_gguf_for_codex), so auto-defaulting to it produced
a copy-pasteable command that fails immediately whenever the loaded model
isn't GGUF. Add useActiveModelIsGguf() (looks up the active checkpoint in
the chat runtime store) and a correction effect that steers the auto-pick
away from codex unless the loaded model qualifies, without ever touching a
choice the user made by hand.
- Detection runs via shutil.which on the Studio backend host, which isn't
the same machine as the browser in a tunnel/remote session. Reword the
'installed'/'detected' copy to say so explicitly when the tunnel URL is
in use, instead of implying the check ran on the viewer's own device.
* Rework auto-default per review: loopback gating + inline GGUF check
Replaces the previous approach with the exact shape discussed on the PR:
- Export isLoopbackHost/normalizeHost from agent-command.ts. The detection
endpoint runs shutil.which on the Studio backend, which only describes the
browser's own machine when the base this panel targets resolves to
loopback. For a LAN or tunnel/remote base, gate the whole thing off --
don't mark anything as "detected" and don't let it drive the default --
instead of just relabeling the copy.
- Drop the separate GGUF-correction effect and useActiveModelIsGguf hook.
Read useChatRuntimeStore.getState().activeGgufVariant inline inside the
existing detection effect's .then() (so it doesn't need to sit in the
effect's deps), and pick the first detected agent that isn't codex unless
the loaded model is GGUF, leaving the existing default untouched when no
compatible agent is detected.
Verified both branches (loopback vs LAN/tunnel base, gguf vs non-gguf,
manual pick preserved, no-compatible-agent fallback) with a standalone
port of the .then() logic.
* Address latest Codex findings: stale detection, model swap, cache
- Clear detectedAgents (and skip the network call entirely) when the panel
leaves a loopback base, instead of leaving a previous loopback detection
result marked 'installed' for a command that now targets a LAN/tunnel/
remote host.
- Add a separate, network-free correction effect keyed on the live
activeGgufVariant: if codex was auto-picked while a GGUF model was loaded
and the user then switches to a transformers-backed model while this panel
stays mounted, steer away from codex instead of leaving a command that
unsloth_cli's _require_gguf_for_codex will now reject. Never touches a
manual pick.
- Drop coding-agents.ts's module-lifetime cache. Installed-CLI detection is
environment state, not a persisted setting, so a stale positive/negative
from before the user installed something (or reopened the tab) is worse
than one extra cheap local API call per mount; keep only the in-flight
de-dupe for concurrent callers.
Verified the correction-effect logic (gguf->non-gguf swap with/without a
fallback, still-gguf no-op, manual pick never overridden) with a standalone
port of the effect.
* Make the codex/GGUF auto-pick symmetric in both directions
The correction effect only steered away from codex when the model stopped
being GGUF; it never steered back toward codex if the model became GGUF
*after* a non-GGUF-gated fallback had already picked something else (e.g.
codex is the only detected CLI, a transformers model is loaded so the
selection correctly falls back to the claude default, then the user loads a
GGUF model while the panel stays mounted -- codex never gets reconsidered).
Consolidate into one effect that re-derives the preferred detected agent
from scratch whenever detectedAgents or activeGgufVariant changes, in either
direction, instead of only reacting to the codex-specific downgrade case.
The fetch effect now only populates detectedAgents/availableAgents; this
effect is the single source of truth for what gets auto-picked from that
list. Never overrides a manual choice.
Verified both transition directions plus the manual-pick-survives and
initial-detection cases with a standalone port of the derivation logic.
* Reset the auto-pick to the default when it stops being trustworthy
Two more real gaps from the latest Codex pass on d988f52:
- The unified derivation effect only handled the case where a *different*
detected agent could take over. If codex was the only detected agent and
auto-picked while a GGUF model was loaded, then the model stopped being
GGUF, 'preferred' came back undefined and the effect silently left the
selection on codex -- exactly the command unsloth_cli's
_require_gguf_for_codex now rejects. Fall back to DEFAULT_AGENT in that
case instead of leaving it untouched.
- Leaving a loopback base cleared detectedAgents (so the 'installed' badges
correctly disappear) but left whatever agent had been auto-picked from
that now-stale, server-side-only detection still selected. Reset to
DEFAULT_AGENT there too, unless the user picked by hand.
Introduces a shared DEFAULT_AGENT constant instead of repeating the "claude"
literal at each reset site. Verified all five cases (both new resets, both
manual-pick-survives variants, and the existing multi-detected-agent
fallback still preferring another compatible agent over resetting) with a
standalone port of the effects.
* Derive GGUF-ness from the actual loaded state, not just the variant string
activeGgufVariant only covers an HF-repo GGUF pick (a specific quant
variant string). A direct local .gguf file -- custom folder, LM
Studio, or drag-drop -- is just as much a GGUF the codex preflight
(unsloth_cli's _require_gguf_for_codex) would accept, but it never has
a "variant" to report, so it read as non-GGUF here even though
/api/inference/status correctly reports is_gguf: true for it. That
mismatch could leave a Codex-only install not auto-selected, or reset
an auto-picked Codex, for a model that actually supports it.
Combined activeGgufVariant with activeNativePathToken (covers the
drag-drop/picked-file case) and ggufContextLength (only ever populated
when the backend last reported is_gguf: true for the active model, see
applyActiveModelStatusToStore) so all three paths a model can be GGUF
through are covered, matching the same is_gguf-or-equivalent check
hasGgufSource already applies to a staged pick elsewhere in this
codebase.
* Clear stale native-path token on a non-GGUF status refresh
When a native (drag-dropped or picked) GGUF was loaded and the backend later
switches to a transformers model outside the UI load path, refresh() adopts the
new /api/inference/status via setCheckpoint and applyActiveModelStatusToStore.
Those reset activeGgufVariant and ggufContextLength but never clear
activeNativePathToken, so the isGguf OR stays true after the switch and a
Codex-only detection auto-selects unsloth start codex for a non-GGUF model its
preflight rejects.
Drop activeNativePathToken in applyActiveModelStatusToStore whenever the status
is non-GGUF. A real GGUF load reports is_gguf: true, so its token is preserved
(the load path owns it); only a non-GGUF status clears it.
* Add the AGPL-3.0 header to the new studio contract test
* Fix/adjust agent detection for PR #6909
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com>
608 lines
23 KiB
Python
608 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
|