fix: config-key shadow dropped mmproj on GGUF vision model reload

A GGUF vision model (e.g. Gemma-4-14B) served correct image descriptions
on its FIRST load after a restart but hallucinated an identical answer for
every image on every subsequent load — the image was silently flattened to
a "[image_url content]" text placeholder and the model never saw pixels.

Root cause was a config-key mismatch that self-polluted the in-memory
config. On-demand loads arrive as a basename (Gemma-...gguf) while the real
models.json entry is keyed by full path. record_vram_delta() resolved the
write target via _config_for_model_key(), which — unlike _config_for_model()
— did NOT fall back to basename/alias matching, so it returned {} and then
persisted a NEW basename-keyed entry holding ONLY the measured_* fields (no
mmproj, no n_ctx). On the next load _config_for_model()'s exact-match hit
that stripped basename entry FIRST, before the basename loop that would have
found the real full-path config, so mmproj was dropped, supports_vision went
False, and the vision projector never loaded.

Fix:
- Add _resolve_config_key(): returns the actual self.config key for a model
  (exact -> alias -> basename), the single canonical key readers and writers
  must agree on.
- Route _config_for_model() through it; give _config_for_model_key() the same
  basename/alias fallback so it can no longer return {} for a basename.
- record_vram_delta()/_persist() now read and persist measured fields under
  the canonical key, merging into the real config instead of spawning a
  stripped shadow entry.

Verified: after restart the 14B loads with "mmproj ... (vision enabled)" on
every reload and three distinct test images produce three distinct, accurate
descriptions; the measured-VRAM writeback now logs "(force_vram_update)"
(real config resolved) instead of the old "(no used_vram_gb)" (empty config).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoSpEthysqmseCc6Geizty
parent 086c6543
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.1.59" __version__ = "0.1.60"
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
# expandable_segments lets the allocator return freed pages to the driver even # expandable_segments lets the allocator return freed pages to the driver even
......
...@@ -1608,25 +1608,25 @@ class MultiModelManager: ...@@ -1608,25 +1608,25 @@ class MultiModelManager:
for model_type in self._registered_types_for(model_name): for model_type in self._registered_types_for(model_name):
self._remember_registered_type(alias, model_type) self._remember_registered_type(alias, model_type)
def _config_for_model(self, name) -> dict: def _resolve_config_key(self, name) -> Optional[str]:
"""Per-model config dict, tolerant of the id form the caller used. """Return the ACTUAL ``self.config`` key holding this model's config, tolerant
of the id form the caller used (exact id → alias map → basename /
``self.config`` is keyed by the registration id (usually the model's full basename-without-extension). Returns None when no entry matches.
path), but on-demand loads often arrive as a *basename* (e.g.
``gemma-…​.gguf``). A bare ``self.config.get(basename)`` then misses and Writers (e.g. ``record_vram_delta``) MUST persist under this canonical key,
returns ``{}``, so every per-model setting (n_ctx, flash_attn, parser, not the raw request id: an on-demand load arrives as a *basename* while the
cache quant, …) is silently dropped and global defaults are used. Resolve real entry is keyed by full path, so writing measured fields under the
through: exact id → alias map → basename / basename-without-extension.""" basename creates a SECOND, stripped entry (only the measured_* keys, no
mmproj / n_ctx / parser). ``_config_for_model``'s exact-match then finds that
stripped basename entry FIRST on the next load and silently drops mmproj —
the vision projector never loads and the model answers from text alone."""
if not name: if not name:
return {} return None
cfg = self.config.get(name) if self.config.get(name):
if cfg: return name
return cfg
target = self.model_aliases.get(name) target = self.model_aliases.get(name)
if target and target != name: if target and target != name and self.config.get(target):
cfg = self.config.get(target) return target
if cfg:
return cfg
import os import os
base = os.path.basename(str(name)) base = os.path.basename(str(name))
base_noext = base[:-5] if base.endswith(".gguf") else base base_noext = base[:-5] if base.endswith(".gguf") else base
...@@ -1636,7 +1636,23 @@ class MultiModelManager: ...@@ -1636,7 +1636,23 @@ class MultiModelManager:
kbase = os.path.basename(str(key)) kbase = os.path.basename(str(key))
kbase_noext = kbase[:-5] if kbase.endswith(".gguf") else kbase kbase_noext = kbase[:-5] if kbase.endswith(".gguf") else kbase
if kbase == base or kbase_noext == base_noext: if kbase == base or kbase_noext == base_noext:
return kcfg return key
return None
def _config_for_model(self, name) -> dict:
"""Per-model config dict, tolerant of the id form the caller used.
``self.config`` is keyed by the registration id (usually the model's full
path), but on-demand loads often arrive as a *basename* (e.g.
``gemma-…​.gguf``). A bare ``self.config.get(basename)`` then misses and
returns ``{}``, so every per-model setting (n_ctx, flash_attn, parser,
cache quant, …) is silently dropped and global defaults are used. Resolve
through: exact id → alias map → basename / basename-without-extension."""
if not name:
return {}
key = self._resolve_config_key(name)
if key is not None:
return self.config.get(key) or {}
return {} return {}
def set_assigned_models(self, keys) -> None: def set_assigned_models(self, keys) -> None:
...@@ -1922,6 +1938,13 @@ class MultiModelManager: ...@@ -1922,6 +1938,13 @@ class MultiModelManager:
cfg = self.config.get(f"{prefix}:{bare}", {}) cfg = self.config.get(f"{prefix}:{bare}", {})
if cfg: if cfg:
return cfg return cfg
# Basename/alias fallback (mirrors _config_for_model): an on-demand load
# arrives as a basename while the entry is keyed by full path. Without this
# the caller gets {} and any writeback creates a stripped duplicate entry
# that later shadows the real config (mmproj/n_ctx dropped).
key = self._resolve_config_key(model_key)
if key is not None:
return self.config.get(key) or {}
return {} return {}
def is_allowed_model(self, requested_or_resolved: str, model_type: str = None) -> bool: def is_allowed_model(self, requested_or_resolved: str, model_type: str = None) -> bool:
...@@ -2570,7 +2593,15 @@ class MultiModelManager: ...@@ -2570,7 +2593,15 @@ class MultiModelManager:
free_after = self._free_vram_snapshot() free_after = self._free_vram_snapshot()
if free_after < 0: if free_after < 0:
return return
cfg = self._config_for_model_key(model_key) # Persist measured fields onto the model's REAL config entry, under the key
# it is actually stored (usually a full path), not the raw request id (often
# a basename). Writing under the basename would spawn a second, stripped
# entry {measured_* only} that _config_for_model's exact-match then returns
# FIRST on the next load — dropping mmproj so vision silently breaks.
canon_key = self._resolve_config_key(model_key) or model_key
cfg = self.config.get(canon_key)
if not cfg:
cfg = self._config_for_model_key(model_key)
cfgd = cfg if isinstance(cfg, dict) else {} cfgd = cfg if isinstance(cfg, dict) else {}
force_update = bool(cfgd.get("force_vram_update")) force_update = bool(cfgd.get("force_vram_update"))
user_pinned = cfgd.get("used_vram_gb") is not None user_pinned = cfgd.get("used_vram_gb") is not None
...@@ -2646,10 +2677,10 @@ class MultiModelManager: ...@@ -2646,10 +2677,10 @@ class MultiModelManager:
pass pass
try: try:
working[field] = value working[field] = value
self.config[model_key] = dict(working) self.config[canon_key] = dict(working)
from codai.admin.routes import config_manager from codai.admin.routes import config_manager
if config_manager is not None: if config_manager is not None:
config_manager.persist_model_field(model_key, field, value) config_manager.persist_model_field(canon_key, field, value)
print(f" Saved {field}={value} for '{model_key}' " print(f" Saved {field}={value} for '{model_key}' "
f"({'force_vram_update' if force_update else 'no used_vram_gb'})") f"({'force_vram_update' if force_update else 'no used_vram_gb'})")
except Exception as e: except Exception as e:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment