From 94d21c45123c7ed2712d1ecf67cd119948ca87aa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 4 Apr 2026 14:08:14 -0400 Subject: [PATCH] =?UTF-8?q?fix(settings):=20audit=20pass=20=E2=80=94=20mod?= =?UTF-8?q?el=20auto-pull=20on=20startup,=20background=5Fmodel=20empty-str?= =?UTF-8?q?ing=20bug,=20base=20URL=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/views/SettingsView.vue | 2 +- src/fabledassistant/app.py | 58 ++++++++++++++++---------- src/fabledassistant/routes/admin.py | 4 ++ src/fabledassistant/routes/settings.py | 4 +- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 32a2722..37a8a56 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2564,7 +2564,7 @@ FABLE_API_KEY=<your-api-key>

Voice (Speech-to-Speech)

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: pip install faster-whisper kokoro soundfile

diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 1924188..3798017 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -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. diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index e891ae4..0b2df86 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -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}) diff --git a/src/fabledassistant/routes/settings.py b/src/fabledassistant/routes/settings.py index 979075e..b5e94f9 100644 --- a/src/fabledassistant/routes/settings.py +++ b/src/fabledassistant/routes/settings.py @@ -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)