Config auto-detection: - config_detect: read the checkpoint embedding shape as an architecture fingerprint (header-only for safetensors, reuses the open GGUF reader); 7B=[152064,3584], 1.5B=[151936,1536]; orientation-agnostic for shape-reversed GGUF files - Auto-detect is the new default config_name; resolves the family before any heavy load, or fails fast with an actionable error (.bin/.pt and unknown families cannot be fingerprinted) - an explicit config_name that contradicts the weights self-corrects to the detected family with one WARNING (reconcile_config) - loader: friendly shape pre-check in _apply_state_dict names the offending tensors and hints at config_name instead of torch's raw size-mismatch RuntimeError Dropdown dedup: - VibeVoice-Large removed from config_name options (duplicate of 7B); kept as a legacy alias so saved workflows still load (normalize at node + loader entry; validate_inputs(**kwargs) override makes core skip its combo-membership check) - node resolves Auto-detect before computing the cache identity so the request key matches the consumer's bundle-derived key (no per-run churn) Console noise: - demote ~30 internal INFO logs to DEBUG across loaders/patcher/registry - drop two stray tie_weights prints; tied lm_head.weight no longer warned as missing (expected under tie_word_embeddings) Tests: 1060 passed / 5 pre-existing failures / 4 skipped
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Tests for config_name de-duplication + legacy alias normalization.
|
|
|
|
Plan 2026-08-27 (Phase 1): 'VibeVoice-Large' was removed from the dropdown
|
|
(it mapped to the exact same packaged config as 'VibeVoice-7B'), but saved
|
|
workflows carrying the removed value must keep loading via the alias map.
|
|
"""
|
|
|
|
import inspect
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from ComfyUI_VibeVoice.modules.external_loader import (
|
|
AUTO_CONFIG_NAME,
|
|
ASR_CONFIG_NAMES,
|
|
EXTERNAL_CONFIG_OPTIONS,
|
|
_get_packaged_config_path,
|
|
is_asr_config_name,
|
|
normalize_config_name,
|
|
)
|
|
from ComfyUI_VibeVoice.nodes.external_loader_node import VibeVoiceExternalLoaderNode
|
|
|
|
|
|
class TestNormalizeConfigName:
|
|
"""normalize_config_name(): alias map + passthrough semantics."""
|
|
|
|
def test_legacy_large_maps_to_7b(self):
|
|
assert normalize_config_name("VibeVoice-Large") == "VibeVoice-7B"
|
|
|
|
def test_alias_lookup_is_case_insensitive(self):
|
|
assert normalize_config_name("vibevoice-large") == "VibeVoice-7B"
|
|
assert normalize_config_name("VIBEVOICE-LARGE") == "VibeVoice-7B"
|
|
|
|
def test_current_options_pass_through_unchanged(self):
|
|
for name in EXTERNAL_CONFIG_OPTIONS:
|
|
assert normalize_config_name(name) == name
|
|
|
|
def test_unknown_value_returned_as_is(self):
|
|
assert normalize_config_name("Not-A-Model") == "Not-A-Model"
|
|
|
|
def test_empty_and_none_safe(self):
|
|
assert normalize_config_name("") == ""
|
|
assert normalize_config_name(None) is None
|
|
|
|
|
|
class TestDropdownDeDuplication:
|
|
"""The visible option list no longer contains the ambiguous alias."""
|
|
|
|
def test_large_not_in_options(self):
|
|
assert "VibeVoice-Large" not in EXTERNAL_CONFIG_OPTIONS
|
|
|
|
def test_7b_still_in_options(self):
|
|
assert "VibeVoice-7B" in EXTERNAL_CONFIG_OPTIONS
|
|
assert "VibeVoice-1.5B" in EXTERNAL_CONFIG_OPTIONS
|
|
|
|
def test_large_has_no_packaged_default_anymore(self):
|
|
assert _get_packaged_config_path("VibeVoice-Large") == ""
|
|
|
|
def test_7b_still_maps_to_large_config_file(self):
|
|
# The packaged FILE keeps its historical name; only the option was
|
|
# removed. Pin the mapping so a rename accident breaks the build.
|
|
path = _get_packaged_config_path("VibeVoice-7B")
|
|
assert path.replace("\\", "/").endswith(
|
|
"src/vibevoice/configs/default_VibeVoice-Large_config.json"
|
|
)
|
|
|
|
|
|
class TestNodeValidateInputs:
|
|
"""validate_inputs override: accepts legacy aliases, rejects junk.
|
|
|
|
The **kwargs signature is load-bearing: ComfyUI core's execution.py skips
|
|
its built-in combo-membership check when the node's validate function has
|
|
var-keywords, which is what lets saved 'VibeVoice-Large' workflows queue.
|
|
"""
|
|
|
|
def test_signature_has_varkw(self):
|
|
spec = inspect.getfullargspec(VibeVoiceExternalLoaderNode.validate_inputs)
|
|
assert spec.varkw is not None
|
|
|
|
def test_accepts_legacy_alias(self):
|
|
assert VibeVoiceExternalLoaderNode.validate_inputs(
|
|
config_name="VibeVoice-Large"
|
|
) is True
|
|
|
|
def test_accepts_current_options(self):
|
|
for name in EXTERNAL_CONFIG_OPTIONS:
|
|
assert VibeVoiceExternalLoaderNode.validate_inputs(
|
|
config_name=name
|
|
) is True
|
|
|
|
def test_rejects_unknown_with_message(self):
|
|
result = VibeVoiceExternalLoaderNode.validate_inputs(config_name="bogus")
|
|
assert isinstance(result, str)
|
|
assert "bogus" in result
|
|
assert "VibeVoice-7B" in result
|
|
|
|
|
|
class TestExecuteNormalization:
|
|
"""execute() normalizes BEFORE identity computation (plan F11/D1)."""
|
|
|
|
def test_legacy_alias_reaches_loader_as_7b(self):
|
|
fake_bundle = {"model": MagicMock()}
|
|
|
|
with patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
|
|
return_value=fake_bundle,
|
|
) as mock_load, patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
|
|
return_value="/fake/path/model.safetensors",
|
|
):
|
|
VibeVoiceExternalLoaderNode.execute(
|
|
model_file="model.safetensors",
|
|
config_name="VibeVoice-Large",
|
|
attention_mode="sdpa",
|
|
quantize_llm_4bit=False,
|
|
dtype="auto",
|
|
)
|
|
|
|
assert mock_load.call_args[1]["config_name"] == "VibeVoice-7B"
|
|
|
|
def test_identity_computed_with_normalized_name(self):
|
|
"""Legacy and modern workflows must produce the same cache identity."""
|
|
fake_bundle = {"model": MagicMock()}
|
|
|
|
with patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
|
|
return_value=fake_bundle,
|
|
), patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
|
|
return_value="/fake/path/model.safetensors",
|
|
), patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.identity_for_external",
|
|
return_value="external:key",
|
|
) as mock_identity:
|
|
VibeVoiceExternalLoaderNode.execute(
|
|
model_file="model.safetensors",
|
|
config_name="VibeVoice-Large",
|
|
attention_mode="sdpa",
|
|
quantize_llm_4bit=False,
|
|
dtype="auto",
|
|
)
|
|
|
|
assert mock_identity.call_args[0][1] == "VibeVoice-7B"
|
|
|
|
|
|
class TestLoaderEntryNormalization:
|
|
"""load_external_vibevoice_model normalizes at its own entry (defense in
|
|
depth: direct callers/tests bypass the node)."""
|
|
|
|
def test_tts_entry_normalizes_before_dispatch(self, tmp_path):
|
|
"""The ASR dispatch gate sees the NORMALIZED name."""
|
|
fake_weights = tmp_path / "model.safetensors"
|
|
fake_weights.write_bytes(b"not a real checkpoint")
|
|
|
|
_captured = []
|
|
|
|
with patch(
|
|
"ComfyUI_VibeVoice.modules.external_loader.is_asr_config_name",
|
|
wraps=lambda name: (_captured.append(name), False)[1],
|
|
), patch(
|
|
"ComfyUI_VibeVoice.modules.external_loader.load_external_vibevoice_asr_model",
|
|
):
|
|
try:
|
|
from ComfyUI_VibeVoice.modules.external_loader import (
|
|
load_external_vibevoice_model,
|
|
)
|
|
|
|
load_external_vibevoice_model(
|
|
weight_path=str(fake_weights),
|
|
config_name="VibeVoice-Large",
|
|
)
|
|
except Exception:
|
|
# Downstream loading will fail on the fake file — the
|
|
# assertion below only needs the dispatch-gate capture.
|
|
pass
|
|
|
|
assert _captured == ["VibeVoice-7B"]
|
|
|
|
def test_asr_entry_normalizes_idempotently(self, tmp_path):
|
|
"""The ASR function's own normalization is a safe no-op on canonical
|
|
names and maps aliases when called directly."""
|
|
fake_weights = tmp_path / "asr.safetensors"
|
|
fake_weights.write_bytes(b"not a real checkpoint")
|
|
|
|
captured = []
|
|
|
|
def _fake_resolve(weight_path, config_name):
|
|
captured.append(config_name)
|
|
raise RuntimeError("stop here")
|
|
|
|
with patch(
|
|
"ComfyUI_VibeVoice.modules.external_loader._load_weight_state_dict",
|
|
return_value={},
|
|
), patch(
|
|
"ComfyUI_VibeVoice.modules.external_loader.resolve_sidecar_config",
|
|
side_effect=_fake_resolve,
|
|
):
|
|
from ComfyUI_VibeVoice.modules.external_loader import (
|
|
load_external_vibevoice_asr_model,
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="stop here"):
|
|
load_external_vibevoice_asr_model(
|
|
weight_path=str(fake_weights),
|
|
config_name="VibeVoice-ASR",
|
|
)
|
|
|
|
assert captured == ["VibeVoice-ASR"]
|
|
|
|
|
|
class TestAutoDetectOption:
|
|
"""Step 4.1: the Auto-detect sentinel option (plan 2026-08-27, D6)."""
|
|
|
|
def test_options_content_and_order(self):
|
|
assert EXTERNAL_CONFIG_OPTIONS == [
|
|
"Auto-detect",
|
|
"VibeVoice-1.5B",
|
|
"VibeVoice-7B",
|
|
"VibeVoice-Realtime-0.5B",
|
|
"VibeVoice-ASR",
|
|
]
|
|
|
|
def test_auto_constant_is_first_option(self):
|
|
assert AUTO_CONFIG_NAME == "Auto-detect"
|
|
assert EXTERNAL_CONFIG_OPTIONS[0] == AUTO_CONFIG_NAME
|
|
|
|
def test_auto_is_not_asr(self):
|
|
assert is_asr_config_name("Auto-detect") is False
|
|
assert AUTO_CONFIG_NAME not in ASR_CONFIG_NAMES
|
|
|
|
def test_normalize_passes_auto_through(self):
|
|
assert normalize_config_name(AUTO_CONFIG_NAME) == AUTO_CONFIG_NAME
|
|
|
|
def test_validate_inputs_accepts_auto(self):
|
|
assert VibeVoiceExternalLoaderNode.validate_inputs(
|
|
config_name=AUTO_CONFIG_NAME
|
|
) is True
|
|
|
|
def test_schema_default_is_auto_detect(self):
|
|
with patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node."
|
|
"list_external_model_files",
|
|
return_value=["fake.safetensors"],
|
|
):
|
|
schema = VibeVoiceExternalLoaderNode.define_schema()
|
|
cfg = next(i for i in schema.inputs if i.id == "config_name")
|
|
assert cfg.default == "Auto-detect"
|
|
assert cfg.options[0] == "Auto-detect"
|
|
|
|
|
|
class TestNodeAutoDetectResolution:
|
|
"""execute() resolves Auto-detect BEFORE the identity is computed.
|
|
|
|
The consumer (generation.py) keys its patcher cache off the bundle's
|
|
recorded model_name, so the node's request key must carry the same
|
|
resolved name — otherwise the unload-before-load gate and patcher cache
|
|
churn on every run (deviation from plan D7; see implementation notes).
|
|
"""
|
|
|
|
def _execute(self, config_name, resolve_side_effect):
|
|
fake_bundle = {"model": MagicMock()}
|
|
with patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
|
|
return_value=fake_bundle,
|
|
) as mock_load, patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
|
|
return_value="/fake/path/model.safetensors",
|
|
), patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.resolve_auto_config_name",
|
|
side_effect=resolve_side_effect,
|
|
) as mock_resolve, patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.identity_for_external",
|
|
return_value="external:key",
|
|
) as mock_identity:
|
|
result = VibeVoiceExternalLoaderNode.execute(
|
|
model_file="model.safetensors",
|
|
config_name=config_name,
|
|
attention_mode="sdpa",
|
|
quantize_llm_4bit=False,
|
|
dtype="auto",
|
|
)
|
|
return result, mock_load, mock_resolve, mock_identity
|
|
|
|
def test_auto_resolved_before_identity(self):
|
|
_, mock_load, mock_resolve, mock_identity = self._execute(
|
|
"Auto-detect", resolve_side_effect=lambda wp: "VibeVoice-7B"
|
|
)
|
|
mock_resolve.assert_called_once_with("/fake/path/model.safetensors")
|
|
# Identity and the loader both see the RESOLVED family name.
|
|
assert mock_identity.call_args[0][1] == "VibeVoice-7B"
|
|
assert mock_load.call_args[1]["config_name"] == "VibeVoice-7B"
|
|
|
|
def test_explicit_selection_skips_detection(self):
|
|
_, mock_load, mock_resolve, mock_identity = self._execute(
|
|
"VibeVoice-1.5B", resolve_side_effect=lambda wp: "SHOULD-NOT-RUN"
|
|
)
|
|
mock_resolve.assert_not_called()
|
|
assert mock_identity.call_args[0][1] == "VibeVoice-1.5B"
|
|
assert mock_load.call_args[1]["config_name"] == "VibeVoice-1.5B"
|
|
|
|
def test_auto_inconclusive_fails_fast_without_loading(self):
|
|
fake_bundle = {"model": MagicMock()}
|
|
with patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.load_external_vibevoice_model",
|
|
return_value=fake_bundle,
|
|
) as mock_load, patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.folder_paths.get_full_path_or_raise",
|
|
return_value="/fake/path/model.bin",
|
|
), patch(
|
|
"ComfyUI_VibeVoice.nodes.external_loader_node.resolve_auto_config_name",
|
|
side_effect=ValueError("could not determine architecture"),
|
|
):
|
|
with pytest.raises(ValueError, match="could not determine"):
|
|
VibeVoiceExternalLoaderNode.execute(
|
|
model_file="model.bin",
|
|
config_name="Auto-detect",
|
|
attention_mode="sdpa",
|
|
quantize_llm_4bit=False,
|
|
dtype="auto",
|
|
)
|
|
mock_load.assert_not_called()
|