Compare commits

...

6 Commits

Author SHA1 Message Date
Godwin Iheuwa
9eb46ad6d0
Merge 76eb8f26af into d9dc02a7d6 2026-01-13 20:21:49 +00:00
Acly
d9dc02a7d6
Support "lite" version of alibaba-pai Z-Image Controlnet (#11849)
* reduced number of control layers (3) compared to full model
2026-01-13 15:03:53 -05:00
Alexander Piskun
c543ad81c3
fix(api-nodes-gemini): raise exception when no candidates due to safety block (#11848) 2026-01-13 08:30:13 -08:00
Godwin Iheuwa
76eb8f26af
Merge branch 'master' into fix/custom-node-import-failure-context 2026-01-07 14:48:55 +05:30
RUiNtheExtinct
d23a9633d9 Merge origin/master into fix/custom-node-import-failure-context 2025-12-29 23:08:34 +05:30
RUiNtheExtinct
1bb97c480d fix: Show custom node import failure reasons in summary
When custom nodes fail to import, the summary now shows the exception
type and message instead of just "(IMPORT FAILED)".

Before:
  0.0 seconds (IMPORT FAILED): custom_nodes/my_node

After:
  0.0 seconds (IMPORT FAILED: ImportError: No module named 'xyz'): custom_nodes/my_node

Changes:
- Add IMPORT_FAILED_REASONS dict to store failure context
- Capture exception type and first line of message (max 100 chars)
- Include failure reason in import summary output

This helps users quickly diagnose why custom nodes failed to load
without needing to scroll through the full traceback.

Fixes #11454
2025-12-28 12:16:03 +05:30
5 changed files with 125 additions and 10 deletions

View File

@ -130,7 +130,7 @@ def get_parts_by_type(response: GeminiGenerateContentResponse, part_type: Litera
Returns:
List of response parts matching the requested type.
"""
if response.candidates is None:
if not response.candidates:
if response.promptFeedback and response.promptFeedback.blockReason:
feedback = response.promptFeedback
raise ValueError(
@ -141,14 +141,24 @@ def get_parts_by_type(response: GeminiGenerateContentResponse, part_type: Litera
"try changing it to `IMAGE+TEXT` to view the model's reasoning and understand why image generation failed."
)
parts = []
for part in response.candidates[0].content.parts:
if part_type == "text" and part.text:
parts.append(part)
elif part.inlineData and part.inlineData.mimeType == part_type:
parts.append(part)
elif part.fileData and part.fileData.mimeType == part_type:
parts.append(part)
# Skip parts that don't match the requested type
blocked_reasons = []
for candidate in response.candidates:
if candidate.finishReason and candidate.finishReason.upper() == "IMAGE_PROHIBITED_CONTENT":
blocked_reasons.append(candidate.finishReason)
continue
if candidate.content is None or candidate.content.parts is None:
continue
for part in candidate.content.parts:
if part_type == "text" and part.text:
parts.append(part)
elif part.inlineData and part.inlineData.mimeType == part_type:
parts.append(part)
elif part.fileData and part.fileData.mimeType == part_type:
parts.append(part)
if not parts and blocked_reasons:
raise ValueError(f"Gemini API blocked the request. Reasons: {blocked_reasons}")
return parts

View File

@ -244,6 +244,10 @@ class ModelPatchLoader:
elif 'control_all_x_embedder.2-1.weight' in sd: # alipai z image fun controlnet
sd = z_image_convert(sd)
config = {}
if 'control_layers.4.adaLN_modulation.0.weight' not in sd:
config['n_control_layers'] = 3
config['additional_in_dim'] = 17
config['refiner_control'] = True
if 'control_layers.14.adaLN_modulation.0.weight' in sd:
config['n_control_layers'] = 15
config['additional_in_dim'] = 17

View File

@ -2110,6 +2110,10 @@ EXTENSION_WEB_DIRS = {}
# Dictionary of successfully loaded module names and associated directories.
LOADED_MODULE_DIRS = {}
# Dictionary of import failure reasons keyed by module path.
# Used to provide diagnostic information in the import summary.
IMPORT_FAILED_REASONS: dict[str, str] = {}
def get_module_name(module_path: str) -> str:
"""
@ -2224,6 +2228,9 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom
logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or NODES_LIST (need one).")
return False
except Exception as e:
# Capture one-line failure reason for the import summary
error_msg = str(e).split('\n')[0][:100] # First line, max 100 chars
IMPORT_FAILED_REASONS[module_path] = f"{type(e).__name__}: {error_msg}"
logging.warning(traceback.format_exc())
logging.warning(f"Cannot import {module_path} module for custom nodes: {e}")
return False
@ -2271,7 +2278,12 @@ async def init_external_custom_nodes():
if n[2]:
import_message = ""
else:
import_message = " (IMPORT FAILED)"
# Include failure reason if available
reason = IMPORT_FAILED_REASONS.get(n[1], "")
if reason:
import_message = f" (IMPORT FAILED: {reason})"
else:
import_message = " (IMPORT FAILED)"
logging.info("{:6.1f} seconds{}: {}".format(n[0], import_message, n[1]))
logging.info("")

View File

View File

@ -0,0 +1,89 @@
"""Tests for custom node import failure reason reporting."""
import pytest
import tempfile
import os
import shutil
from unittest.mock import patch, MagicMock
import asyncio
class TestImportFailureReasons:
"""Test that import failures include diagnostic information."""
def test_import_failure_reason_format(self):
"""Test that failure reason is formatted correctly."""
# Simulate the formatting logic
exception = ImportError("No module named 'missing_dep'")
error_msg = str(exception).split('\n')[0][:100]
reason = f"{type(exception).__name__}: {error_msg}"
assert reason == "ImportError: No module named 'missing_dep'"
def test_import_failure_reason_truncation(self):
"""Test that long error messages are truncated."""
long_msg = "a" * 200
exception = ValueError(long_msg)
error_msg = str(exception).split('\n')[0][:100]
reason = f"{type(exception).__name__}: {error_msg}"
# Should be truncated to 100 chars for the message part
assert len(error_msg) == 100
assert reason.startswith("ValueError: ")
def test_import_failure_reason_multiline(self):
"""Test that only first line of error is used."""
multi_line_msg = "First line\nSecond line\nThird line"
exception = RuntimeError(multi_line_msg)
error_msg = str(exception).split('\n')[0][:100]
reason = f"{type(exception).__name__}: {error_msg}"
assert reason == "RuntimeError: First line"
assert "Second line" not in reason
def test_import_failure_reason_various_exceptions(self):
"""Test formatting for various exception types."""
test_cases = [
(ModuleNotFoundError("No module named 'foo'"), "ModuleNotFoundError: No module named 'foo'"),
(SyntaxError("invalid syntax"), "SyntaxError: invalid syntax"),
(AttributeError("'NoneType' object has no attribute 'bar'"), "AttributeError: 'NoneType' object has no attribute 'bar'"),
(FileNotFoundError("[Errno 2] No such file"), "FileNotFoundError: [Errno 2] No such file"),
]
for exception, expected in test_cases:
error_msg = str(exception).split('\n')[0][:100]
reason = f"{type(exception).__name__}: {error_msg}"
assert reason == expected, f"Failed for {type(exception).__name__}"
class TestImportSummaryOutput:
"""Test the import summary output format."""
def test_summary_message_with_reason(self):
"""Test that summary includes reason when available."""
reason = "ImportError: No module named 'xyz'"
import_message = f" (IMPORT FAILED: {reason})"
assert import_message == " (IMPORT FAILED: ImportError: No module named 'xyz')"
def test_summary_message_without_reason(self):
"""Test fallback when no reason is available."""
reason = ""
if reason:
import_message = f" (IMPORT FAILED: {reason})"
else:
import_message = " (IMPORT FAILED)"
assert import_message == " (IMPORT FAILED)"
def test_summary_format_string(self):
"""Test the full summary line format."""
time_taken = 0.05
import_message = " (IMPORT FAILED: ImportError: missing module)"
module_path = "/path/to/custom_nodes/my_node"
summary_line = "{:6.1f} seconds{}: {}".format(time_taken, import_message, module_path)
assert "0.1 seconds" in summary_line
assert "(IMPORT FAILED: ImportError: missing module)" in summary_line
assert module_path in summary_line