fix(llm): normalize Ollama model tags to lowercase

Ollama's /api/tags returns whatever casing was used at pull time
(e.g. 'gemma3:12B' if the user ran 'ollama pull gemma3:12B'), but
/api/chat rejects mixed-case tags with a 400. The two code paths
are inconsistent, which surfaces the capitalized tag in the model
dropdown and then silently kills every chat request against it.

Lowercase on read (get_installed_models), on settings write
(update_settings_route), and on ensure_model() input so a legacy
mixed-case user setting can't trigger a spurious re-pull at
startup. The dropdown and stored settings are now always in the
form Ollama will actually accept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 18:02:52 -04:00
parent 95135a665b
commit 9a851de624
2 changed files with 21 additions and 3 deletions
+9 -1
View File
@@ -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)
+12 -2
View File
@@ -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: