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> <h2>Voice (Speech-to-Speech)</h2>
<p class="section-desc"> <p class="section-desc">
Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS). 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> Install dependencies first: <code>pip install faster-whisper kokoro soundfile</code>
</p> </p>
<div class="checkbox-field"> <div class="checkbox-field">
+35 -23
View File
@@ -212,10 +212,10 @@ def create_app() -> Quart:
async def _warm_user_models() -> None: async def _warm_user_models() -> None:
""" """
Warm whichever chat model(s) users have selected in Settings, then prime Pull any user-configured models that are missing from Ollama, then warm
the KV cache with each user's system prompt so the first real message is fast. 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. Falls back silently if no user preferences exist or Ollama is unreachable.
""" """
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
@@ -223,50 +223,62 @@ def create_app() -> Quart:
from fabledassistant.models import async_session from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting 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: try:
async with async_session() as session: async with async_session() as session:
rows = await session.execute( rows = await session.execute(
sa_select(Setting.user_id, Setting.value).where( sa_select(Setting.user_id, Setting.key, Setting.value).where(
Setting.key == "default_model", Setting.key.in_(["default_model", "background_model"]),
Setting.value.isnot(None), Setting.value.isnot(None),
Setting.value != "", Setting.value != "",
) )
) )
user_model_pairs: list[tuple[int, str]] = list(rows) settings_rows: list[tuple[int, str, str]] = list(rows)
except Exception: except Exception:
logger.debug("Could not read user model preferences from DB", exc_info=True) logger.debug("Could not read user model preferences from DB", exc_info=True)
return return
if not user_model_pairs: if not settings_rows:
logger.debug("No user model preferences found; skipping warm-up") logger.debug("No user model preferences found; skipping warm-up")
return 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: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status() 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: except Exception:
logger.debug("Could not reach Ollama to check installed models", exc_info=True) logger.debug("Could not reach Ollama to check installed models", exc_info=True)
return 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() warmed: set[str] = set()
for user_id_val, model in user_model_pairs: for user_id_val, model in user_chat_models:
base = model.removesuffix(":latest") if model in installed:
if model in installed or f"{base}:latest" in installed or base in installed:
if model not in warmed: if model not in warmed:
await _warm_model(model) await _warm_model(model)
warmed.add(model) warmed.add(model)
await _prime_kv_cache(user_id_val, 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: async def _pull_model(model: str, warm: bool = False) -> None:
try: try:
@@ -281,11 +293,11 @@ def create_app() -> Quart:
if warm: if warm:
await _warm_model(model) await _warm_model(model)
# Warm user-preferred chat models that are already installed. # Ensure system-default models are present, then pull/warm user-configured ones.
# Also ensure the embedding model is pulled (no warm needed). asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=True))
asyncio.create_task(_warm_user_models())
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False)) asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_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. # After models are pulled, backfill embeddings for existing notes.
# Runs in the background so it never blocks the server from accepting requests. # 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(): async def update_base_url():
data = await request.get_json() data = await request.get_json()
url = (data.get("base_url") or "").strip().rstrip("/") 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() uid = get_current_user_id()
await set_setting(uid, "base_url", url) 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}) 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: if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400 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 # Delete the DB row so get_setting() falls back to Config defaults
# rather than returning "" and breaking model resolution everywhere. # rather than returning "" and breaking model resolution everywhere.
_MODEL_KEYS = frozenset({"default_model"}) _MODEL_KEYS = frozenset({"default_model", "background_model"})
to_save = {} to_save = {}
for k, v in data.items(): for k, v in data.items():
str_v = str(v) str_v = str(v)