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:
2026-04-04 14:08:14 -04:00
parent d9bd16633f
commit 94d21c4512
4 changed files with 42 additions and 26 deletions
+1 -1
View File
@@ -2564,7 +2564,7 @@ FABLE_API_KEY=&lt;your-api-key&gt;</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
View File
@@ -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.
+4
View File
@@ -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})
+2 -2
View File
@@ -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)