stop two models sharing a weights path from overwriting each other

A request for LTX-2.3-10Eros loaded LTX-2.3-PinkCherry: a different 15.9 GB
transformer, silently, and from an entry that was DISABLED.

self.config is keyed <kind>:<model_name> where model_name is the weights path,
so entries sharing a path clobber one another and only the last survives. That
is not a corner case -- it is how one base checkpoint is served under several
configurations: lisa / lisa-32k differ only in context length, 10Eros /
PinkCherry only in which GGUF is their transformer. PinkCherry comes second in
models.json, so it won, parked or not.

The VRAM estimator already disambiguated this way, by the id the caller asked
for, after lisa-32k was once sized at lisa 51.97 GB. The config used to LOAD
the model did not. Now both do:

* every type setter stores a config under its ALIAS as well as its path, so a
  sibling is reachable by the only name that distinguishes it. The path key
  keeps its old meaning (last writer wins) for callers that know only a path.
* _config_for_model_key takes the requested id and prefers the entry whose
  alias matches.

Applied to all eight setters rather than video alone -- the collision is the
same for audio, image and embedding, and fixing one type is how this survived
being found twice already.

Also: build_gguf_pipeline_components passed `model_name` as the diffusers repo
supplying the component configs. Once siblings became reachable by alias that
can BE the alias -- "LTX-2.3-10Eros", not a repo -- and from_single_file
rejects it. The repo is whatever the entry names in `path`, every time.

Verified live: LTX-2.3-10Eros now loads its own transformer and the pipeline
assembles, 8/8 components.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZnmWyKjWZerwsb27jZ
parent 720498e8
...@@ -723,9 +723,18 @@ def build_gguf_pipeline_components(model_name: str, cfg: Optional[Dict[str, Any] ...@@ -723,9 +723,18 @@ def build_gguf_pipeline_components(model_name: str, cfg: Optional[Dict[str, Any]
# fails with "stable-diffusion-v1-5 does not appear to have a file # fails with "stable-diffusion-v1-5 does not appear to have a file
# named config.json". The base pipeline's own component subfolder # named config.json". The base pipeline's own component subfolder
# is the right answer and is already at hand. # is the right answer and is already at hand.
#
# It must come from the ENTRY'S `path`, not from model_name.
# model_name is whatever id resolution produced, and once sibling
# entries became reachable by alias that can be the alias itself --
# "LTX-2.3-10Eros", which is not a repo and which from_single_file
# rejects with "Invalid `pretrained_model_name_or_path` provided".
# The repo supplying the component configs is the one the entry
# names, every time.
_base = str((cfg or {}).get("path") or "").strip() or model_name
model = cls.from_single_file( model = cls.from_single_file(
path.strip(), path.strip(),
config=model_name, config=_base,
subfolder=name, subfolder=name,
quantization_config=GGUFQuantizationConfig(compute_dtype=dtype), quantization_config=GGUFQuantizationConfig(compute_dtype=dtype),
torch_dtype=dtype, torch_dtype=dtype,
......
...@@ -2215,7 +2215,7 @@ class MultiModelManager: ...@@ -2215,7 +2215,7 @@ class MultiModelManager:
"""Add an audio transcription model and download/cache it if needed.""" """Add an audio transcription model and download/cache it if needed."""
if model_name not in self.audio_models: if model_name not in self.audio_models:
self.audio_models.append(model_name) self.audio_models.append(model_name)
self.config[f"audio:{model_name}"] = config or {} self._store_type_config("audio", model_name, config)
self._remember_registered_type(model_name, "audio") self._remember_registered_type(model_name, "audio")
_be = (config or {}).get("backend") if isinstance(config, dict) else None _be = (config or {}).get("backend") if isinstance(config, dict) else None
...@@ -2415,7 +2415,7 @@ class MultiModelManager: ...@@ -2415,7 +2415,7 @@ class MultiModelManager:
def set_tts_model(self, model_name: str, config: Dict = None): def set_tts_model(self, model_name: str, config: Dict = None):
"""Set the text-to-speech model and download/cache it if needed.""" """Set the text-to-speech model and download/cache it if needed."""
self.tts_model = model_name self.tts_model = model_name
self.config[f"tts:{model_name}"] = config or {} self._store_type_config("tts", model_name, config)
self._remember_registered_type(model_name, "tts") self._remember_registered_type(model_name, "tts")
# Download/cache the model at startup if it's a URL or HF ID # Download/cache the model at startup if it's a URL or HF ID
...@@ -2430,7 +2430,7 @@ class MultiModelManager: ...@@ -2430,7 +2430,7 @@ class MultiModelManager:
"""Add an image generation model and download/cache it if needed.""" """Add an image generation model and download/cache it if needed."""
if model_name not in self.image_models: if model_name not in self.image_models:
self.image_models.append(model_name) self.image_models.append(model_name)
self.config[f"image:{model_name}"] = config or {} self._store_type_config("image", model_name, config)
self._remember_registered_type(model_name, "image") self._remember_registered_type(model_name, "image")
# For image models, we don't download at startup since they may be large # For image models, we don't download at startup since they may be large
...@@ -2442,7 +2442,7 @@ class MultiModelManager: ...@@ -2442,7 +2442,7 @@ class MultiModelManager:
"""Add a vision model and download/cache it if needed.""" """Add a vision model and download/cache it if needed."""
if model_name not in self.vision_models: if model_name not in self.vision_models:
self.vision_models.append(model_name) self.vision_models.append(model_name)
self.config[f"vision:{model_name}"] = config or {} self._store_type_config("vision", model_name, config)
self._remember_registered_type(model_name, "vision") self._remember_registered_type(model_name, "vision")
resolved_model = self.load_model(model_name) resolved_model = self.load_model(model_name)
...@@ -2456,7 +2456,7 @@ class MultiModelManager: ...@@ -2456,7 +2456,7 @@ class MultiModelManager:
"""Add a video generation model (t2v / i2v / v2v).""" """Add a video generation model (t2v / i2v / v2v)."""
if model_name not in self.video_models: if model_name not in self.video_models:
self.video_models.append(model_name) self.video_models.append(model_name)
self.config[f"video:{model_name}"] = config or {} self._store_type_config("video", model_name, config)
self._remember_registered_type(model_name, "video") self._remember_registered_type(model_name, "video")
print(f"Registered video model: {model_name}") print(f"Registered video model: {model_name}")
...@@ -2464,7 +2464,7 @@ class MultiModelManager: ...@@ -2464,7 +2464,7 @@ class MultiModelManager:
"""Add a music/audio generation model (MusicGen, AudioLDM2, …).""" """Add a music/audio generation model (MusicGen, AudioLDM2, …)."""
if model_name not in self.audio_gen_models: if model_name not in self.audio_gen_models:
self.audio_gen_models.append(model_name) self.audio_gen_models.append(model_name)
self.config[f"audio_gen:{model_name}"] = config or {} self._store_type_config("audio_gen", model_name, config)
self._remember_registered_type(model_name, "audio_gen") self._remember_registered_type(model_name, "audio_gen")
print(f"Registered audio-gen model: {model_name}") print(f"Registered audio-gen model: {model_name}")
...@@ -2472,7 +2472,7 @@ class MultiModelManager: ...@@ -2472,7 +2472,7 @@ class MultiModelManager:
"""Add a text/image embedding model.""" """Add a text/image embedding model."""
if model_name not in self.embedding_models: if model_name not in self.embedding_models:
self.embedding_models.append(model_name) self.embedding_models.append(model_name)
self.config[f"embedding:{model_name}"] = config or {} self._store_type_config("embedding", model_name, config)
self._remember_registered_type(model_name, "embedding") self._remember_registered_type(model_name, "embedding")
print(f"Registered embedding model: {model_name}") print(f"Registered embedding model: {model_name}")
...@@ -2480,7 +2480,7 @@ class MultiModelManager: ...@@ -2480,7 +2480,7 @@ class MultiModelManager:
"""Add a depth estimation / segmentation / object detection model.""" """Add a depth estimation / segmentation / object detection model."""
if model_name not in self.spatial_models: if model_name not in self.spatial_models:
self.spatial_models.append(model_name) self.spatial_models.append(model_name)
self.config[f"spatial:{model_name}"] = config or {} self._store_type_config("spatial", model_name, config)
self._remember_registered_type(model_name, "spatial") self._remember_registered_type(model_name, "spatial")
print(f"Registered spatial model: {model_name}") print(f"Registered spatial model: {model_name}")
...@@ -2517,6 +2517,32 @@ class MultiModelManager: ...@@ -2517,6 +2517,32 @@ class MultiModelManager:
return str(target) return str(target)
return None return None
def _store_type_config(self, kind: str, model_name: str, config: Dict = None):
"""Store a model's config under its type key, and under its ALIAS too.
``self.config`` is keyed ``<kind>:<model_name>`` where model_name is the
weights path -- so two entries that share one path overwrite each
other and only the last survives. That is not a corner case: it is how
a base checkpoint is served under several configurations (lisa /
lisa-32k differ only in context; LTX-2.3-10Eros / -PinkCherry differ
only in which GGUF is their transformer).
Measured 2026-10-27: a request for LTX-2.3-10Eros loaded PinkCherry's
15.9 GB transformer, because PinkCherry comes second in models.json
and had clobbered the shared key -- while being DISABLED, which should
have kept it out of the registry altogether.
So an entry carrying an alias is stored under that alias as well. The
path key keeps its existing meaning (last writer wins, which is what
every caller that knows only a path expects) and the alias key makes
the sibling reachable by the only name that distinguishes it.
"""
cfg = config or {}
self.config[f"{kind}:{model_name}"] = cfg
alias = str((cfg or {}).get("alias") or "").strip()
if alias and alias != model_name:
self.config[f"{kind}:{alias}"] = cfg
def set_model_alias(self, alias: str, model_name: str): def set_model_alias(self, alias: str, model_name: str):
"""Register an alias for a model.""" """Register an alias for a model."""
self.model_aliases[alias] = model_name self.model_aliases[alias] = model_name
...@@ -2840,8 +2866,27 @@ class MultiModelManager: ...@@ -2840,8 +2866,27 @@ class MultiModelManager:
return True return True
return model_type == "text" and "vision" in types return model_type == "text" and "vision" in types
def _config_for_model_key(self, model_key: str) -> Dict[str, Any]: def _config_for_model_key(self, model_key: str,
"""Return config for a key, falling back to compatible multi-type keys.""" requested_id: str = None) -> Dict[str, Any]:
"""Return config for a key, falling back to compatible multi-type keys.
``requested_id`` is the id the caller actually asked for, before alias
resolution, and it is the ONLY thing that separates sibling entries
sharing one weights path. _get_model_used_vram_gb already disambiguates
this way (lisa vs lisa-32k, 178000 ctx vs 32768 of the same file); the
config used to LOAD the model did not, so it picked whichever sibling
happened to register the path.
Measured 2026-10-27: LTX-2.3-10Eros and LTX-2.3-PinkCherry both point
at diffusers/LTX-2.3-Diffusers, differing only in which GGUF is their
transformer. A request for 10Eros collapsed to the shared path and
loaded PinkCherry's 15.9 GB checkpoint -- the wrong model entirely,
and silently, since both are "valid" configs for that path.
"""
if requested_id:
for _k, _c in (self.config or {}).items():
if isinstance(_c, dict) and _c.get("alias") == requested_id:
return _c
cfg = self.config.get(model_key, {}) cfg = self.config.get(model_key, {})
if cfg: if cfg:
return cfg return cfg
...@@ -5708,7 +5753,7 @@ class MultiModelManager: ...@@ -5708,7 +5753,7 @@ class MultiModelManager:
# Per-model "load" = pre-loaded (treat as loadall for this model). # Per-model "load" = pre-loaded (treat as loadall for this model).
# Per-model "on-request" = load when needed with VRAM management. # Per-model "on-request" = load when needed with VRAM management.
# ===================================================================== # =====================================================================
per_model_cfg = self._config_for_model_key(model_key) per_model_cfg = self._config_for_model_key(model_key, _requested_id)
per_model_load_mode = per_model_cfg.get("load_mode") # "load" | "on-request" | None per_model_load_mode = per_model_cfg.get("load_mode") # "load" | "on-request" | None
if per_model_load_mode == "on-request": if per_model_load_mode == "on-request":
......
"""Two entries sharing one weights path must not be confused for each other.
LTX-2.3-10Eros and LTX-2.3-PinkCherry both point at diffusers/LTX-2.3-Diffusers
and differ only in which GGUF is their transformer. A request for 10Eros
collapsed to the shared path and loaded PinkCherry's 15.9 GB checkpoint --
the wrong model, silently, because both are "valid" configs for that path.
The VRAM estimator already disambiguated this way for lisa vs lisa-32k (same
weights file, 178000 ctx vs 32768). The config used to LOAD did not.
"""
from codai.models.manager import MultiModelManager
def _mgr():
m = object.__new__(MultiModelManager)
m.config = {
"video:diffusers/LTX-2.3-Diffusers": {
"alias": "LTX-2.3-PinkCherry",
"component_quantization": {"transformer": "/g/PinkCherry.gguf"},
"used_vram_gb": 22.0,
},
"video:LTX-2.3-10Eros": {
"alias": "LTX-2.3-10Eros",
"component_quantization": {"transformer": "/g/10Eros.gguf"},
"used_vram_gb": 21.0,
},
}
return m
def test_the_requested_id_picks_its_own_entry():
m = _mgr()
cfg = m._config_for_model_key("video:diffusers/LTX-2.3-Diffusers",
"LTX-2.3-10Eros")
assert cfg["component_quantization"]["transformer"] == "/g/10Eros.gguf"
assert cfg["used_vram_gb"] == 21.0
def test_the_other_sibling_gets_its_own_too():
m = _mgr()
cfg = m._config_for_model_key("video:diffusers/LTX-2.3-Diffusers",
"LTX-2.3-PinkCherry")
assert cfg["component_quantization"]["transformer"] == "/g/PinkCherry.gguf"
assert cfg["used_vram_gb"] == 22.0
def test_without_a_requested_id_the_old_behaviour_is_unchanged():
# Every existing caller passes nothing, and must keep getting the
# key-based lookup exactly as before.
m = _mgr()
cfg = m._config_for_model_key("video:diffusers/LTX-2.3-Diffusers")
assert cfg["alias"] == "LTX-2.3-PinkCherry"
def test_an_id_matching_no_alias_falls_back_to_the_key():
m = _mgr()
cfg = m._config_for_model_key("video:diffusers/LTX-2.3-Diffusers",
"something-else")
assert cfg["alias"] == "LTX-2.3-PinkCherry"
def test_a_model_with_no_sibling_is_unaffected():
m = object.__new__(MultiModelManager)
m.config = {"video:solo": {"alias": "solo", "used_vram_gb": 5.0}}
assert m._config_for_model_key("video:solo", "solo")["used_vram_gb"] == 5.0
assert m._config_for_model_key("video:solo")["used_vram_gb"] == 5.0
def test_request_model_passes_the_requested_id_through():
import inspect
src = inspect.getsource(MultiModelManager.request_model)
assert "_config_for_model_key(model_key, _requested_id)" in src, (
"the loading config must disambiguate by the id the caller asked for")
# ---------------------------------------------------------------- registration
def _fresh():
m = object.__new__(MultiModelManager)
m.config = {}
m.video_models = []
m._registered_types = {}
m._remember_registered_type = lambda *a, **k: None
return m
def test_two_entries_sharing_a_path_both_survive_registration():
"""The collision that caused the wrong model to load.
self.config is keyed <kind>:<path>, so a second entry with the same path
overwrote the first. LTX-2.3-10Eros and -PinkCherry share
diffusers/LTX-2.3-Diffusers and differ only in their GGUF; PinkCherry came
second, so a request for 10Eros loaded PinkCherry's 15.9 GB transformer.
"""
m = _fresh()
eros = {"alias": "LTX-2.3-10Eros",
"component_quantization": {"transformer": "/g/10Eros.gguf"}}
pink = {"alias": "LTX-2.3-PinkCherry",
"component_quantization": {"transformer": "/g/PinkCherry.gguf"}}
m.set_video_model("diffusers/LTX-2.3-Diffusers", eros)
m.set_video_model("diffusers/LTX-2.3-Diffusers", pink)
# Both reachable by their own alias…
assert m.config["video:LTX-2.3-10Eros"] is eros
assert m.config["video:LTX-2.3-PinkCherry"] is pink
# …and the lookup picks the right one.
got = m._config_for_model_key("video:diffusers/LTX-2.3-Diffusers",
"LTX-2.3-10Eros")
assert got["component_quantization"]["transformer"] == "/g/10Eros.gguf"
def test_the_path_key_keeps_its_old_meaning():
# Callers that know only a path still get the last writer, unchanged.
m = _fresh()
m.set_video_model("p", {"alias": "a", "v": 1})
m.set_video_model("p", {"alias": "b", "v": 2})
assert m.config["video:p"]["v"] == 2
def test_an_entry_without_an_alias_adds_no_extra_key():
m = _fresh()
m.set_video_model("p", {"v": 1})
assert set(m.config) == {"video:p"}
def test_an_alias_equal_to_the_model_name_adds_no_duplicate():
m = _fresh()
m.set_video_model("p", {"alias": "p"})
assert set(m.config) == {"video:p"}
def test_every_type_setter_uses_the_shared_store():
# A fix applied to one model type and not the others is how this bug
# survived: it is the same collision for audio, image, embedding…
import inspect
src = inspect.getsource(MultiModelManager)
import re
leftovers = re.findall(r'self\.config\[f"[a-z_]+:\{model_name\}"\] = config',
src)
assert not leftovers, leftovers
assert src.count("_store_type_config(") >= 9
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