fix(settings): audit pass — model auto-pull on startup, background_model empty-string bug, base URL validation
- Startup now pulls Config.OLLAMA_MODEL (system default chat model) — previously only embedding and background models were pulled; the primary chat model was skipped - _warm_user_models expanded to also pull user-configured default_model and background_model overrides that are missing from Ollama, rather than logging and skipping them; pulls run before warm/KV-cache priming - Add background_model to _MODEL_KEYS in settings route so clearing the dropdown deletes the DB row instead of saving "", which caused Ollama failures in tag suggestions, title generation, project summaries, and RSS classification - Add http/https scheme validation to PUT /api/admin/base-url matching the CalDAV route pattern; a bad value no longer silently breaks invite/password-reset links - Update admin voice config description: "Reload models" button exists to avoid a server restart, so the old "restart required" text was misleading Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2564,7 +2564,7 @@ FABLE_API_KEY=<your-api-key></pre>
|
||||
<h2>Voice (Speech-to-Speech)</h2>
|
||||
<p class="section-desc">
|
||||
Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS).
|
||||
Models load at startup — a server restart is required after changing these settings.
|
||||
Save settings then click "Reload models" to apply without restarting the server.
|
||||
Install dependencies first: <code>pip install faster-whisper kokoro soundfile</code>
|
||||
</p>
|
||||
<div class="checkbox-field">
|
||||
|
||||
+35
-23
@@ -212,10 +212,10 @@ def create_app() -> Quart:
|
||||
|
||||
async def _warm_user_models() -> None:
|
||||
"""
|
||||
Warm whichever chat model(s) users have selected in Settings, then prime
|
||||
the KV cache with each user's system prompt so the first real message is fast.
|
||||
Pull any user-configured models that are missing from Ollama, then warm
|
||||
them and prime the KV cache with each user's system prompt.
|
||||
|
||||
Only warms models that are already installed in Ollama — never auto-pulls.
|
||||
Handles both default_model (chat) and background_model user overrides.
|
||||
Falls back silently if no user preferences exist or Ollama is unreachable.
|
||||
"""
|
||||
from sqlalchemy import select as sa_select
|
||||
@@ -223,50 +223,62 @@ def create_app() -> Quart:
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
|
||||
# 1. Collect (user_id, model) pairs for all users with a saved default_model.
|
||||
# 1. Collect all user model preferences (both chat and background).
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
sa_select(Setting.user_id, Setting.value).where(
|
||||
Setting.key == "default_model",
|
||||
sa_select(Setting.user_id, Setting.key, Setting.value).where(
|
||||
Setting.key.in_(["default_model", "background_model"]),
|
||||
Setting.value.isnot(None),
|
||||
Setting.value != "",
|
||||
)
|
||||
)
|
||||
user_model_pairs: list[tuple[int, str]] = list(rows)
|
||||
settings_rows: list[tuple[int, str, str]] = list(rows)
|
||||
except Exception:
|
||||
logger.debug("Could not read user model preferences from DB", exc_info=True)
|
||||
return
|
||||
|
||||
if not user_model_pairs:
|
||||
if not settings_rows:
|
||||
logger.debug("No user model preferences found; skipping warm-up")
|
||||
return
|
||||
|
||||
# 2. Ask Ollama which models are currently installed.
|
||||
# 2. Build the set of unique models to ensure, and the list of
|
||||
# (user_id, chat_model) pairs for KV-cache priming.
|
||||
all_models: set[str] = set()
|
||||
user_chat_models: list[tuple[int, str]] = []
|
||||
for user_id_val, key, model in settings_rows:
|
||||
all_models.add(model)
|
||||
if key == "default_model":
|
||||
user_chat_models.append((user_id_val, model))
|
||||
|
||||
# 3. Ask Ollama which models are currently installed.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
|
||||
resp.raise_for_status()
|
||||
installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
|
||||
raw_installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
|
||||
installed: set[str] = raw_installed | {
|
||||
n.removesuffix(":latest") for n in raw_installed if n.endswith(":latest")
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("Could not reach Ollama to check installed models", exc_info=True)
|
||||
return
|
||||
|
||||
# 3. Warm each unique model, then prime KV cache per user.
|
||||
# 4. Pull any user-configured models that are missing.
|
||||
for model in all_models:
|
||||
if model not in installed:
|
||||
logger.info("User-configured model '%s' not installed; pulling...", model)
|
||||
await _pull_model(model)
|
||||
installed.add(model)
|
||||
|
||||
# 5. Warm each unique chat model, then prime KV cache per user.
|
||||
warmed: set[str] = set()
|
||||
for user_id_val, model in user_model_pairs:
|
||||
base = model.removesuffix(":latest")
|
||||
if model in installed or f"{base}:latest" in installed or base in installed:
|
||||
for user_id_val, model in user_chat_models:
|
||||
if model in installed:
|
||||
if model not in warmed:
|
||||
await _warm_model(model)
|
||||
warmed.add(model)
|
||||
await _prime_kv_cache(user_id_val, model)
|
||||
else:
|
||||
logger.info(
|
||||
"User-preferred model '%s' is not installed; skipping warm-up "
|
||||
"(install it via Settings → Models to enable auto-warm)",
|
||||
model,
|
||||
)
|
||||
|
||||
async def _pull_model(model: str, warm: bool = False) -> None:
|
||||
try:
|
||||
@@ -281,11 +293,11 @@ def create_app() -> Quart:
|
||||
if warm:
|
||||
await _warm_model(model)
|
||||
|
||||
# Warm user-preferred chat models that are already installed.
|
||||
# Also ensure the embedding model is pulled (no warm needed).
|
||||
asyncio.create_task(_warm_user_models())
|
||||
# Ensure system-default models are present, then pull/warm user-configured ones.
|
||||
asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=True))
|
||||
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
|
||||
asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_MODEL, warm=False))
|
||||
asyncio.create_task(_warm_user_models())
|
||||
|
||||
# After models are pulled, backfill embeddings for existing notes.
|
||||
# Runs in the background so it never blocks the server from accepting requests.
|
||||
|
||||
@@ -195,6 +195,10 @@ async def get_base_url_setting():
|
||||
async def update_base_url():
|
||||
data = await request.get_json()
|
||||
url = (data.get("base_url") or "").strip().rstrip("/")
|
||||
if url:
|
||||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if scheme not in ("http", "https"):
|
||||
return jsonify({"error": "Base URL must use http or https"}), 400
|
||||
uid = get_current_user_id()
|
||||
await set_setting(uid, "base_url", url)
|
||||
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
|
||||
|
||||
@@ -65,10 +65,10 @@ async def update_settings_route():
|
||||
if installed and model not in installed:
|
||||
return jsonify({"error": f"Model '{model}' is not installed"}), 400
|
||||
|
||||
# Empty string for default_model means "reset to system default".
|
||||
# Empty string for model keys means "reset to system default".
|
||||
# Delete the DB row so get_setting() falls back to Config defaults
|
||||
# rather than returning "" and breaking model resolution everywhere.
|
||||
_MODEL_KEYS = frozenset({"default_model"})
|
||||
_MODEL_KEYS = frozenset({"default_model", "background_model"})
|
||||
to_save = {}
|
||||
for k, v in data.items():
|
||||
str_v = str(v)
|
||||
|
||||
Reference in New Issue
Block a user