diff --git a/src/fabledassistant/routes/settings.py b/src/fabledassistant/routes/settings.py index 33d25cd..2c6a8a6 100644 --- a/src/fabledassistant/routes/settings.py +++ b/src/fabledassistant/routes/settings.py @@ -64,6 +64,15 @@ async def update_settings_route(): if not isinstance(data, dict): return jsonify({"error": "Expected a JSON object"}), 400 + # Normalize model names to lowercase before validation. Ollama's /api/tags + # preserves whatever casing was used at pull time, but /api/chat rejects + # mixed-case tags — lowercasing here keeps the two paths consistent and + # means the stored setting is always in a form Ollama will actually accept. + _MODEL_KEYS = frozenset({"default_model", "background_model"}) + for _key in _MODEL_KEYS: + if _key in data and data[_key]: + data[_key] = str(data[_key]).lower() + if "default_model" in data: installed = await get_installed_models() if data["default_model"]: @@ -74,7 +83,6 @@ async def update_settings_route(): # 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", "background_model"}) to_save = {} for k, v in data.items(): str_v = str(v) diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index acd79da..288df68 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -78,7 +78,14 @@ RAG_AUTO_SNIPPET = 4000 async def get_installed_models() -> set[str]: - """Return set of installed Ollama model names (with and without :latest).""" + """Return set of installed Ollama model names (with and without :latest). + + Tags are normalized to lowercase. Ollama stores whatever casing was used at + pull time (``ollama pull gemma3:12B`` keeps the ``B``), but inference calls + against the capitalized tag can 400 — the two code paths are inconsistent. + Lowercasing on read avoids exposing that mixed-case name in the UI and + keeps the validation check below from accepting a form Ollama will reject. + """ try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") @@ -86,7 +93,7 @@ async def get_installed_models() -> set[str]: data = resp.json() names: set[str] = set() for m in data.get("models", []): - name = m["name"] + name = m["name"].lower() names.add(name) if name.endswith(":latest"): names.add(name.removesuffix(":latest")) @@ -98,6 +105,9 @@ async def get_installed_models() -> set[str]: async def ensure_model(model: str) -> None: """Check if model exists in Ollama, pull if missing.""" + # Match the lowercase normalization in get_installed_models so a legacy + # mixed-case setting doesn't force a spurious re-pull at startup. + model = model.lower() try: installed = await get_installed_models() if model in installed or f"{model}:latest" in installed: