Files
dseditor-ComfyUI-ListHelper/gguf_inference.py
T
2025-12-29 22:34:20 +08:00

1196 lines
48 KiB
Python

import os
import sys
import gc
import time
import re
import subprocess
import platform
import base64
import io
import random
import json
import numpy as np
import folder_paths
import urllib.request
from typing import Optional, Tuple, List
# Suggested models for download
SUGGESTED_MODELS = {
"Download: Z-Image": "https://huggingface.co/unsloth/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-Q5_K_M.gguf",
"Download: Z-Image (Abliterated)": "https://huggingface.co/Mungert/Qwen3-4B-abliterated-GGUF/resolve/main/Qwen3-4B-abliterated-q4_k_m.gguf",
"Download: Qwen": "https://huggingface.co/unsloth/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf",
"Download: Qwen (Abliterated)": "https://huggingface.co/mradermacher/Qwen2.5-VL-7B-Instruct-abliterated-GGUF/resolve/main/Qwen2.5-VL-7B-Instruct-abliterated.Q4_K_M.gguf",
"Download: QwenVL": "https://huggingface.co/mradermacher/Huihui-Qwen3-VL-4B-Instruct-abliterated-GGUF/resolve/main/Huihui-Qwen3-VL-4B-Instruct-abliterated.Q4_K_M.gguf",
}
SUGGESTED_MMPROJ = {
"Download: mmproj": "https://huggingface.co/unsloth/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/mmproj-F16.gguf",
"Download: QwenVL mmproj": "https://huggingface.co/noctrex/Huihui-Qwen3-VL-4B-Instruct-abliterated-i1-GGUF/resolve/main/mmproj-F16.gguf",
}
class GGUFInference:
"""
GGUF Model Inference Node with llama-cpp-python
Supports both text-only and vision models with optional mmproj files
Auto-searches GGUF files from text_encoders and clip folders
"""
def __init__(self):
self.model = None
self.current_model_path = None
self.current_mmproj_path = None
self.clip_model_array = None
self.llama_cpp_available = False
self.failed_versions = [] # Track failed installation versions
self._check_llama_cpp()
def _check_llama_cpp(self):
"""Check if llama-cpp-python is available"""
try:
import llama_cpp
self.llama_cpp_available = True
print("llama-cpp-python is available")
except ImportError:
self.llama_cpp_available = False
print("llama-cpp-python is not installed")
@classmethod
def _download_file(cls, url: str, destination: str) -> bool:
"""Download file from URL with progress reporting"""
try:
def report_progress(block_num, block_size, total_size):
downloaded = block_num * block_size
if total_size > 0:
percent = min(downloaded * 100 / total_size, 100)
downloaded_mb = downloaded / (1024 * 1024)
total_mb = total_size / (1024 * 1024)
print(f"⬇️ {percent:.0f}% ({downloaded_mb:.0f}MB/{total_mb:.0f}MB)", end='\r')
urllib.request.urlretrieve(url, destination, reporthook=report_progress)
print(f"\n✓ Downloaded: {os.path.basename(destination)}")
return True
except Exception as e:
print(f"\n⚠️ Download failed: {e}")
return False
@classmethod
def _get_gguf_files(cls):
"""Get all GGUF files from text_encoders and clip folders"""
gguf_files = []
# Search in text_encoders folder
try:
text_encoder_paths = folder_paths.get_folder_paths("text_encoders")
for path in text_encoder_paths:
if os.path.exists(path):
for file in os.listdir(path):
if file.lower().endswith('.gguf'):
full_path = os.path.join(path, file)
if full_path not in gguf_files:
gguf_files.append(full_path)
except:
pass
# Search in clip folder
try:
clip_paths = folder_paths.get_folder_paths("clip")
for path in clip_paths:
if os.path.exists(path):
for file in os.listdir(path):
if file.lower().endswith('.gguf'):
full_path = os.path.join(path, file)
if full_path not in gguf_files:
gguf_files.append(full_path)
except:
pass
# Add suggested models to the list
suggested_models = list(SUGGESTED_MODELS.keys())
if not gguf_files:
# If no local models, only show suggested models
return suggested_models
# Return local models first, then suggested models
return sorted(gguf_files) + suggested_models
@classmethod
def _get_mmproj_files(cls):
"""Get all mmproj files from text_encoders and clip folders"""
mmproj_files = []
# Search in text_encoders folder
try:
text_encoder_paths = folder_paths.get_folder_paths("text_encoders")
for path in text_encoder_paths:
if os.path.exists(path):
for file in os.listdir(path):
if 'mmproj' in file.lower() and file.lower().endswith('.gguf'):
full_path = os.path.join(path, file)
if full_path not in mmproj_files:
mmproj_files.append(full_path)
except:
pass
# Search in clip folder
try:
clip_paths = folder_paths.get_folder_paths("clip")
for path in clip_paths:
if os.path.exists(path):
for file in os.listdir(path):
if 'mmproj' in file.lower() and file.lower().endswith('.gguf'):
full_path = os.path.join(path, file)
if full_path not in mmproj_files:
mmproj_files.append(full_path)
except:
pass
# Add suggested mmproj to the list
suggested_mmproj = list(SUGGESTED_MMPROJ.keys())
if not mmproj_files:
# If no local mmproj files, show "No mmproj files" and suggested option
return ["No mmproj files"] + suggested_mmproj
# Return local mmproj files first, then suggested option
return sorted(mmproj_files) + suggested_mmproj
@classmethod
def _get_prompt_templates(cls):
"""Get all .md template files from Prompt folder"""
current_dir = os.path.dirname(os.path.abspath(__file__))
prompt_dir = os.path.join(current_dir, "Prompt")
templates = []
if os.path.exists(prompt_dir):
for file in os.listdir(prompt_dir):
if file.lower().endswith('.md'):
templates.append(file)
if not templates:
return ["No Template"]
return sorted(templates)
@classmethod
def _load_template_content(cls, template_name):
"""Load template content"""
if template_name == "No Template" or template_name == "Custom":
return ""
current_dir = os.path.dirname(os.path.abspath(__file__))
template_path = os.path.join(current_dir, "Prompt", template_name)
if os.path.exists(template_path):
try:
with open(template_path, 'r', encoding='utf-8') as f:
return f.read()
except:
return ""
return ""
@classmethod
def INPUT_TYPES(cls):
gguf_files = cls._get_gguf_files()
# Get file names only for dropdown
gguf_names = [os.path.basename(f) if f != "No GGUF files found" else f for f in gguf_files]
# Get mmproj files
mmproj_files = cls._get_mmproj_files()
mmproj_names = [os.path.basename(f) if f != "No mmproj files" else f for f in mmproj_files]
# Get prompt templates
templates = cls._get_prompt_templates()
template_options = ["Custom"] + templates
# Set default model to QwenVL download option if available
default_model = "Download: QwenVL" if "Download: QwenVL" in gguf_names else (gguf_names[0] if gguf_names else "No GGUF files found")
# Set default mmproj to QwenVL mmproj download option if available
default_mmproj = "Download: QwenVL mmproj" if "Download: QwenVL mmproj" in mmproj_names else (mmproj_names[0] if mmproj_names else "No mmproj files")
# Set default template to image_to_prompt.md if available, otherwise Custom
default_template = "image_to_prompt.md" if "image_to_prompt.md" in template_options else (template_options[0] if template_options else "Custom")
return {
"required": {
"model": (gguf_names, {
"default": default_model
}),
"prompt": ("STRING", {
"multiline": True,
"default": ""
}),
"prompt_template": (template_options, {
"default": default_template
}),
"system_prompt": ("STRING", {
"multiline": True,
"default": ""
}),
"max_tokens": ("INT", {
"default": 4096,
"min": 1,
"max": 8192,
"step": 1
}),
"temperature": ("FLOAT", {
"default": 0.7,
"min": 0.0,
"max": 2.0,
"step": 0.1
}),
"top_p": ("FLOAT", {
"default": 0.9,
"min": 0.0,
"max": 1.0,
"step": 0.05
}),
"top_k": ("INT", {
"default": 40,
"min": 0,
"max": 100,
"step": 1
}),
},
"optional": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}),
"keep_model_loaded": ("BOOLEAN", {
"default": False,
"tooltip": "Keep model in memory after inference"
}),
"mmproj_file": (mmproj_names, {
"default": default_mmproj,
"tooltip": "Vision model mmproj file (auto-enabled when image is provided and model is VL type)"
}),
"image": ("IMAGE", {
"tooltip": "Input image for vision model (auto-enables vision mode for VL models)"
}),
"auto_install_llama_cpp": ("BOOLEAN", {
"default": True,
"tooltip": "Auto-install/repair llama-cpp-python (supports all platforms/CUDA versions, prioritizes Basic version for compatibility)"
}),
}
}
RETURN_TYPES = ("STRING", "INT",)
RETURN_NAMES = ("text", "used_seed",)
FUNCTION = "inference"
CATEGORY = "ListHelper"
def _free_memory(self):
"""Free GPU and system memory"""
try:
# Clear chat_handler first (it may hold references to model)
if self.clip_model_array is not None:
try:
# Try to explicitly close/cleanup chat_handler if it has such methods
if hasattr(self.clip_model_array, 'clip_ctx') and self.clip_model_array.clip_ctx is not None:
del self.clip_model_array.clip_ctx
if hasattr(self.clip_model_array, '_clip_free'):
try:
self.clip_model_array._clip_free()
except:
pass
except:
pass
del self.clip_model_array
self.clip_model_array = None
# Force garbage collection after freeing chat_handler
gc.collect()
# Clear model
if self.model is not None:
try:
# Try to explicitly close model context if available
if hasattr(self.model, 'close'):
self.model.close()
if hasattr(self.model, '_ctx') and self.model._ctx is not None:
del self.model._ctx
except:
pass
del self.model
self.model = None
self.current_model_path = None
self.current_mmproj_path = None
# Run garbage collection multiple times to ensure cleanup
for _ in range(3):
gc.collect()
# Try to free CUDA memory if available
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
# Force empty cache again
torch.cuda.empty_cache()
except:
pass
# Memory freed silently
pass
except Exception as e:
print(f"⚠️ Error freeing memory: {e}")
def _free_image_memory(self):
"""Free image-related memory without unloading model"""
try:
# Run garbage collection multiple times
for _ in range(2):
gc.collect()
# Try to free CUDA memory if available
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
# Empty cache again after sync
torch.cuda.empty_cache()
except:
pass
# Image memory freed silently
pass
except Exception as e:
print(f"⚠️ Error freeing image memory: {e}")
def _detect_cuda_version(self):
"""Detect CUDA version from system"""
try:
# Try to get CUDA version from torch
import torch
if torch.cuda.is_available():
cuda_version = torch.version.cuda
if cuda_version:
# Convert 12.8 to 128, 12.1 to 121, etc.
major, minor = cuda_version.split('.')
return f"cu{major}{minor}"
except:
pass
# Try to get CUDA version from nvcc
try:
result = subprocess.run(['nvcc', '--version'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0:
# Parse output like "Cuda compilation tools, release 12.8, V12.8.89"
match = re.search(r'release (\d+)\.(\d+)', result.stdout)
if match:
major, minor = match.groups()
return f"cu{major}{minor}"
except:
pass
return None
def _parse_release_info(self, release):
"""Parse release tag to extract CUDA version, AVX support, and OS info
Example tag formats:
- v0.3.18-cu130-AVX2-win-20251220 -> CUDA 13.0, AVX2, Windows
- v0.3.18-cu128-AVX2-linux-20251220 -> CUDA 12.8, AVX2, Linux
- v0.3.16+cu128avx2 -> CUDA 12.8, AVX2
- v0.3.16+cu121 -> CUDA 12.1, no AVX
- v0.3.16+avx2 -> no CUDA, AVX2
- v0.3.16 -> no CUDA, no AVX
"""
tag_name = release.get('tag_name', '')
# Extract CUDA version (e.g., cu128, cu121, cu130)
# Support both formats: -cu130- and +cu128
cuda_match = re.search(r'[-+]cu(\d+)', tag_name)
cuda_version = f"cu{cuda_match.group(1)}" if cuda_match else None
# Check for AVX2 support (case-insensitive)
has_avx2 = 'avx2' in tag_name.lower()
# Check for AVX (non-AVX2)
has_avx = 'avx' in tag_name.lower() and not has_avx2
return {
'tag_name': tag_name,
'cuda_version': cuda_version,
'has_avx2': has_avx2,
'has_avx': has_avx,
'release': release
}
def _get_github_releases(self, max_releases=15):
"""Get recent releases from GitHub"""
try:
api_url = f"https://api.github.com/repos/JamePeng/llama-cpp-python/releases?per_page={max_releases}"
# Create request with User-Agent header
req = urllib.request.Request(api_url)
req.add_header('User-Agent', 'ComfyUI-GGUFInference/1.0')
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode('utf-8'))
return data # Returns list of releases
except Exception as e:
print(f"Failed to fetch GitHub releases: {e}")
return None
def _find_matching_release(self, releases_list, skip_versions=None):
"""Find matching release and wheel URL from releases list based on system configuration
Args:
releases_list: List of GitHub releases
skip_versions: Set of version tags to skip (previously failed versions)
"""
if not releases_list:
return None
if skip_versions is None:
skip_versions = set()
# Get system info
system = platform.system().lower() # 'windows', 'linux', 'darwin'
py_version = sys.version_info
py_ver = f"cp{py_version.major}{py_version.minor}"
# Detect CUDA version
cuda_version = self._detect_cuda_version()
print("=" * 70)
print("Detecting system configuration:")
print(f" OS: {system}")
print(f" Python: {py_version.major}.{py_version.minor} ({py_ver})")
print(f" CUDA: {cuda_version if cuda_version else 'Not detected'}")
if skip_versions:
print(f" Skipping versions: {', '.join(sorted(skip_versions))}")
print("=" * 70)
# Determine platform suffix
if system == 'windows':
platform_suffix = 'win_amd64.whl'
elif system == 'linux':
platform_suffix = 'linux_x86_64.whl'
elif system == 'darwin':
# macOS - try both arm64 and x86_64
import platform as plt
machine = plt.machine().lower()
if 'arm' in machine or 'aarch64' in machine:
platform_suffix = 'macosx_11_0_arm64.whl'
else:
platform_suffix = 'macosx_10_9_x86_64.whl'
else:
print(f"Unsupported OS: {system}")
return None
# Build priority list for release matching
# Priority: CUDA Basic > CPU Basic
# Note: Only use Basic versions (no AVX/AVX2) for maximum compatibility
# AVX and AVX2 versions are skipped due to compatibility issues
print("=" * 70)
print("Searching through releases...")
print("Note: Skipping AVX/AVX2 versions for better compatibility")
print("=" * 70)
# Try each priority level
priority_filters = []
if cuda_version and system != 'darwin':
# Priority 1: CUDA exact version match (Basic only - no AVX/AVX2)
priority_filters.append({
'name': f'CUDA {cuda_version} Basic (exact match)',
'cuda': cuda_version,
'require_basic': True, # Only match Basic (no AVX, no AVX2)
'allow_cuda_upgrade': False
})
# Priority 2: CUDA compatible version (Basic only - allows newer CUDA)
# CUDA is forward compatible, e.g., cu130 wheels work on cu128 systems
priority_filters.append({
'name': f'CUDA {cuda_version}+ Basic (compatible)',
'cuda': cuda_version,
'require_basic': True,
'allow_cuda_upgrade': True
})
# Priority 3: CPU Basic version (no AVX, no AVX2)
priority_filters.append({
'name': 'CPU Basic',
'cuda': None,
'require_basic': True
})
# Search through releases with priority
for priority in priority_filters:
print(f"Trying priority: {priority['name']}")
for release in releases_list:
release_info = self._parse_release_info(release)
# Skip this version if it's in the skip list
if release_info['tag_name'] in skip_versions:
continue
# Check if this release matches current priority
if priority.get('allow_cuda_upgrade', False):
# Allow CUDA version upgrade (e.g., cu130 wheel on cu128 system)
# Extract numeric CUDA version for comparison
if priority['cuda'] and release_info['cuda_version']:
try:
required_cuda = int(priority['cuda'][2:]) # cu128 -> 128
release_cuda = int(release_info['cuda_version'][2:]) # cu130 -> 130
# Match if release CUDA >= required CUDA (forward compatibility)
cuda_match = release_cuda >= required_cuda
except:
cuda_match = False
else:
cuda_match = (priority['cuda'] is None and release_info['cuda_version'] is None)
else:
# Exact CUDA version match
cuda_match = (priority['cuda'] is None and release_info['cuda_version'] is None) or \
(priority['cuda'] == release_info['cuda_version'])
# Handle require_basic flag: skip all AVX and AVX2 versions
if priority.get('require_basic', False):
# Only match Basic versions (no AVX, no AVX2)
basic_match = not release_info['has_avx'] and not release_info['has_avx2']
else:
# Old logic for backward compatibility (not used in new priority system)
basic_match = True
if cuda_match and basic_match:
# Found matching release, now find wheel for this Python version
print(f" Found matching release: {release_info['tag_name']}")
# Search for wheel with matching Python version
for asset in release.get('assets', []):
name = asset.get('name', '')
download_url = asset.get('browser_download_url', '')
if not name.endswith('.whl'):
continue
# Check if wheel matches Python version and platform
if f"-{py_ver}-{py_ver}-" in name and platform_suffix in name:
print("=" * 70)
print(f"SUCCESS: Found matching wheel!")
print(f" Release: {release_info['tag_name']}")
print(f" Wheel: {name}")
print(f" Download URL: {download_url}")
print("=" * 70)
return (download_url, release_info['tag_name'])
# Release matched but no wheel for this Python version
print(f" No wheel found for Python {py_ver} in this release")
# No matching release found
print("=" * 70)
print("No matching release found for your system configuration")
print("Available releases:")
for release in releases_list[:5]: # Show first 5 releases
release_info = self._parse_release_info(release)
print(f" - {release_info['tag_name']} (CUDA: {release_info['cuda_version']}, AVX: {release_info['has_avx']}, AVX2: {release_info['has_avx2']})")
print("=" * 70)
return None
def _uninstall_llama_cpp(self):
"""Uninstall existing llama-cpp-python"""
print("=" * 70)
print("Uninstalling existing llama-cpp-python...")
print("=" * 70)
try:
# Use pip uninstall with -y flag to auto-confirm
result = subprocess.run([
sys.executable, "-m", "pip", "uninstall", "llama-cpp-python", "-y"
], capture_output=True, text=True)
if result.returncode == 0:
print("=" * 70)
print("SUCCESS: llama-cpp-python uninstalled successfully")
print("=" * 70)
return True
else:
print("=" * 70)
print(f"Uninstall completed with warnings:")
print(result.stdout)
print(result.stderr)
print("=" * 70)
return True # Still consider it successful
except Exception as e:
print("=" * 70)
print(f"ERROR: Failed to uninstall llama-cpp-python: {e}")
print("Please manually uninstall using: pip uninstall llama-cpp-python")
print("=" * 70)
return False
def _install_llama_cpp(self, skip_versions=None):
"""Auto-installation: Auto-detect system and download from GitHub releases
Supports all platforms (Windows/Linux/macOS) and CUDA versions.
Only uses Basic versions (no AVX/AVX2) for maximum compatibility.
Args:
skip_versions: Set of version tags to skip (previously failed versions)
Returns:
Tuple of (success: bool, installed_version: str or None)
"""
if skip_versions is None:
skip_versions = set()
print("=" * 70)
print("Auto-Installing llama-cpp-python")
print("Detecting system configuration and searching through releases...")
print("=" * 70)
# Get recent releases (up to 15)
releases_list = self._get_github_releases(max_releases=15)
if not releases_list:
print("=" * 70)
print("ERROR: Failed to fetch releases from GitHub")
print("Please visit: https://github.com/JamePeng/llama-cpp-python/releases")
print("=" * 70)
return (False, None)
# Find matching release and wheel
result = self._find_matching_release(releases_list, skip_versions=skip_versions)
if not result:
print("=" * 70)
print("ERROR: Could not find a matching wheel for your system")
print("Please visit: https://github.com/JamePeng/llama-cpp-python/releases")
print("And manually download the appropriate wheel for your system")
print("=" * 70)
return (False, None)
wheel_url, version_tag = result
# Download and install
try:
print("=" * 70)
print("Installing llama-cpp-python...")
print(f"Version: {version_tag}")
print("=" * 70)
subprocess.check_call([
sys.executable, "-m", "pip", "install", wheel_url
])
print("=" * 70)
print("SUCCESS: llama-cpp-python installed successfully")
print("IMPORTANT: Please restart ComfyUI to use the GGUF node")
print("=" * 70)
return (True, version_tag)
except Exception as e:
print("=" * 70)
print(f"ERROR: Failed to install llama-cpp-python: {e}")
print("")
print("Please visit: https://github.com/JamePeng/llama-cpp-python/releases")
print("=" * 70)
return (False, version_tag)
def _is_vision_model(self, model_path: str) -> bool:
"""Check if model is a vision model based on filename"""
model_name = os.path.basename(model_path).lower()
return 'vl' in model_name
def _load_model(self, model_path: str, enable_vision: bool = False, mmproj_path: Optional[str] = None, force_reload: bool = False) -> bool:
"""Load GGUF model with llama-cpp-python
Args:
model_path: Path to GGUF model file
enable_vision: Whether to enable vision mode
mmproj_path: Path to mmproj file (for vision models)
force_reload: Force reload even if model is already loaded
"""
try:
# Check if model and mmproj are already loaded
if (not force_reload and
self.model is not None and
self.current_model_path == model_path and
self.current_mmproj_path == mmproj_path):
# Model already loaded, skip verbose output
return True
# Unload previous model if model or mmproj changed, or force reload
if self.model is not None:
# Unload silently
self._free_memory()
# Wait a bit for memory to be fully released
time.sleep(0.5)
if not self.llama_cpp_available:
return False
from llama_cpp import Llama
from llama_cpp.llama_chat_format import Llava15ChatHandler
# Check if this is a vision model
is_vision_model = self._is_vision_model(model_path)
# Only print model name for initial load
print(f"Loading: {os.path.basename(model_path)}")
load_start = time.time()
# Load model with appropriate settings
load_kwargs = {
"model_path": model_path,
"n_ctx": 8192,
"n_gpu_layers": -1, # Use GPU if available
"verbose": False,
}
# Load vision model if it's a VL model and vision is enabled
if is_vision_model and enable_vision and mmproj_path and mmproj_path != "No mmproj files":
try:
chat_handler = Llava15ChatHandler(clip_model_path=mmproj_path)
load_kwargs["chat_handler"] = chat_handler
self.clip_model_array = chat_handler
except Exception as e:
print(f"⚠️ Failed to load mmproj: {e}")
self.model = Llama(**load_kwargs)
self.current_model_path = model_path
self.current_mmproj_path = mmproj_path
load_time = time.time() - load_start
print(f"✓ Loaded ({load_time:.1f}s)")
return True
except Exception as e:
print(f"⚠️ Load failed: {e}")
# Clean up all resources on load failure (silently)
self._free_memory()
return False
def _remove_thinking_tags(self, text: str) -> str:
"""Remove <think>...</think> tags and their content"""
cleaned_text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
cleaned_text = re.sub(r'\n\s*\n', '\n', cleaned_text)
return cleaned_text.strip()
def _detect_excessive_repetition(self, text: str, max_repeat_ratio: float = 0.3) -> Tuple[bool, str]:
"""Detect if text has excessive repetition
Returns:
Tuple of (has_repetition, cleaned_text)
"""
if not text or len(text) < 100:
return (False, text)
# Split into sentences/lines
lines = [line.strip() for line in text.split('\n') if line.strip()]
if len(lines) < 3:
return (False, text)
# Count line repetitions
line_counts = {}
for line in lines:
if len(line) > 10: # Only count substantial lines
line_counts[line] = line_counts.get(line, 0) + 1
# Check for excessive repetition
total_lines = len(lines)
max_repeats = max(line_counts.values()) if line_counts else 0
repeat_ratio = max_repeats / total_lines if total_lines > 0 else 0
if repeat_ratio > max_repeat_ratio and max_repeats > 2:
# Remove duplicate lines, keeping only first occurrence
seen = set()
cleaned_lines = []
for line in lines:
if line not in seen:
cleaned_lines.append(line)
seen.add(line)
elif len(line) <= 10: # Keep short lines (like separators)
cleaned_lines.append(line)
cleaned_text = '\n'.join(cleaned_lines)
print(f"⚠️ Repetition detected, cleaned {len(lines)} → {len(cleaned_lines)} lines")
return (True, cleaned_text)
return (False, text)
def _tensor_to_base64(self, image_tensor) -> str:
"""Convert ComfyUI IMAGE tensor to base64 string"""
try:
from PIL import Image
# ComfyUI IMAGE format: [B, H, W, C] with values in [0, 1]
# Take first image if batch
if len(image_tensor.shape) == 4:
image_tensor = image_tensor[0]
# Convert from [0, 1] to [0, 255]
image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
# Create PIL Image
pil_image = Image.fromarray(image_np)
# Convert to JPEG bytes
buffered = io.BytesIO()
pil_image.save(buffered, format="JPEG", quality=95)
img_bytes = buffered.getvalue()
# Encode to base64
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
return f"data:image/jpeg;base64,{img_base64}"
except Exception as e:
print(f"Error converting image to base64: {e}")
import traceback
traceback.print_exc()
return None
def inference(
self,
model: str,
prompt: str,
prompt_template: str,
system_prompt: str,
max_tokens: int,
temperature: float,
top_p: float,
top_k: int,
seed: int = 0,
keep_model_loaded: bool = False,
mmproj_file: str = "No mmproj files",
image = None,
auto_install_llama_cpp: bool = True,
) -> Tuple[str, int]:
"""Execute GGUF model inference"""
# Check if llama-cpp-python is available
if not self.llama_cpp_available:
if auto_install_llama_cpp:
print("llama-cpp-python not found, attempting auto-installation...")
success, version = self._install_llama_cpp(skip_versions=set(self.failed_versions))
if success:
error_msg = "llama-cpp-python installed successfully!\n\nPlease restart ComfyUI to use the GGUF node."
else:
error_msg = "Failed to install llama-cpp-python.\n\nPlease visit: https://github.com/JamePeng/llama-cpp-python/releases"
else:
error_msg = "ERROR: llama-cpp-python is not installed.\n\nPlease either:\n1. Enable 'auto_install_llama_cpp' option (recommended)\n2. Install manually: pip install llama-cpp-python"
print(error_msg)
return (error_msg, seed)
# Check if model is a suggested download
if model in SUGGESTED_MODELS:
download_url = SUGGESTED_MODELS[model]
filename = os.path.basename(download_url)
# Get clip folder path for downloading
try:
clip_paths = folder_paths.get_folder_paths("clip")
if clip_paths and len(clip_paths) > 0:
download_dir = clip_paths[0]
else:
error_msg = "Error: Cannot find clip folder for downloading model."
print(error_msg)
return (error_msg, seed)
except:
error_msg = "Error: Cannot access clip folder for downloading model."
print(error_msg)
return (error_msg, seed)
model_path = os.path.join(download_dir, filename)
# Check if file already exists
if not os.path.exists(model_path):
print(f"Downloading model...")
if not self._download_file(download_url, model_path):
error_msg = f"⚠️ Download failed: {download_url}"
return (error_msg, seed)
else:
# Get full path from model name
gguf_files = self._get_gguf_files()
model_path = None
for full_path in gguf_files:
if os.path.basename(full_path) == model:
model_path = full_path
break
if model_path is None:
error_msg = f"Error: Model not found: {model}\nPlease place GGUF files in text_encoders or clip folder."
print(error_msg)
return (error_msg, seed)
if not os.path.exists(model_path):
error_msg = f"Error: Model file does not exist: {model_path}"
print(error_msg)
return (error_msg, seed)
# Check if this is a vision model
is_vision_model = self._is_vision_model(model_path)
# Auto-detect vision mode: enable if image is provided and model is VL type
enable_vision = False
if image is not None and is_vision_model:
enable_vision = True
elif image is not None and not is_vision_model:
print(f"⚠️ Image ignored (model is text-only)")
# Get mmproj path if vision is enabled
mmproj_path = None
if enable_vision:
if mmproj_file == "No mmproj files":
print("⚠️ No mmproj file, falling back to text-only mode")
enable_vision = False
else:
# Check if mmproj is a suggested download
if mmproj_file in SUGGESTED_MMPROJ:
download_url = SUGGESTED_MMPROJ[mmproj_file]
filename = os.path.basename(download_url)
# Get clip folder path for downloading
try:
clip_paths = folder_paths.get_folder_paths("clip")
if clip_paths and len(clip_paths) > 0:
download_dir = clip_paths[0]
else:
print("⚠️ Cannot find clip folder")
enable_vision = False
mmproj_path = None
except:
print("⚠️ Cannot access clip folder")
enable_vision = False
mmproj_path = None
if enable_vision:
mmproj_path = os.path.join(download_dir, filename)
# Check if file already exists
if not os.path.exists(mmproj_path):
print(f"Downloading mmproj...")
if not self._download_file(download_url, mmproj_path):
print(f"⚠️ Download failed")
enable_vision = False
mmproj_path = None
else:
# Find full path for mmproj from text_encoders and clip folders
mmproj_files = self._get_mmproj_files()
for full_path in mmproj_files:
if os.path.basename(full_path) == mmproj_file:
mmproj_path = full_path
break
if mmproj_path is None or not os.path.exists(mmproj_path):
print(f"⚠️ mmproj not found: {mmproj_file}")
enable_vision = False
# Load model (first attempt)
load_result = self._load_model(model_path, enable_vision, mmproj_path)
# If loading failed, try force reload once
if not load_result:
print("⚠️ Retrying with force reload...")
load_result = self._load_model(model_path, enable_vision, mmproj_path, force_reload=True)
if not load_result:
# Model loading failed - get the error details from the exception
if auto_install_llama_cpp:
# Check for specific error types
import traceback
error_trace = traceback.format_exc()
# Check for WinError
has_winerror = 'WinError' in error_trace or 'WindowsError' in error_trace
# Check for ggml.dll error
has_ggml_dll_error = 'ggml.dll' in error_trace.lower() or 'cannot load library' in error_trace.lower()
print("=" * 70)
print("ERROR: Model loading failed!")
if has_ggml_dll_error:
# Special handling for ggml.dll error
error_msg = (
"ERROR: Cannot load ggml.dll\n\n"
"This error typically occurs when:\n"
"1. Your CUDA version is incompatible with the installed llama-cpp-python\n"
"2. CUDA runtime libraries are missing or outdated\n\n"
"Recommended solutions:\n"
"1. Update your NVIDIA GPU drivers and CUDA toolkit\n"
"2. Or manually install a compatible llama-cpp-python version from:\n"
" https://github.com/JamePeng/llama-cpp-python/releases\n\n"
"The auto-installer will now try to find an older compatible version..."
)
print(error_msg)
print("=" * 70)
# Get current installed version info
try:
import pkg_resources
current_version = pkg_resources.get_distribution("llama-cpp-python").version
print(f"Current llama-cpp-python version: {current_version}")
except:
current_version = None
if has_winerror or has_ggml_dll_error:
print("Detected compatibility issue - will try previous version")
print("Attempting to uninstall and install previous version...")
else:
print("This may indicate llama-cpp-python is incompatible or corrupted.")
print("Attempting to uninstall and reinstall llama-cpp-python...")
print("=" * 70)
# Try to uninstall and reinstall with previous version
if self._uninstall_llama_cpp():
print("\nNow attempting to install compatible version...")
# Try to install, skipping failed versions
success, new_version = self._install_llama_cpp(skip_versions=set(self.failed_versions))
if success and new_version:
# Track this version if it was just installed
if new_version not in self.failed_versions:
self.failed_versions.append(new_version)
error_msg = f"llama-cpp-python has been reinstalled (version: {new_version})!\n\nIMPORTANT: Please restart ComfyUI to use the new version."
print("=" * 70)
print(error_msg)
print("=" * 70)
return (error_msg, seed)
else:
# Installation failed - add to failed versions if we got a version
if new_version and new_version not in self.failed_versions:
self.failed_versions.append(new_version)
error_msg = (
"Failed to install compatible llama-cpp-python version.\n\n"
"Please visit: https://github.com/JamePeng/llama-cpp-python/releases\n"
"And manually install the appropriate version for your system.\n\n"
)
if has_ggml_dll_error:
error_msg += (
"Note: For ggml.dll errors, ensure your CUDA toolkit is up to date:\n"
"- Update NVIDIA GPU drivers\n"
"- Install latest CUDA toolkit from: https://developer.nvidia.com/cuda-downloads"
)
else:
error_msg = (
"Failed to uninstall llama-cpp-python.\n\n"
"Please manually uninstall using: pip uninstall llama-cpp-python\n"
"Then enable auto_install_llama_cpp option to reinstall."
)
else:
error_msg = (
"Error: Model loading failed.\n\n"
"This may indicate llama-cpp-python is incompatible.\n"
"Please enable 'auto_install_llama_cpp' to automatically fix this,\n"
"or manually reinstall llama-cpp-python."
)
print(error_msg)
return (error_msg, seed)
try:
# Load and apply template
template_content = ""
if prompt_template != "Custom":
template_content = self._load_template_content(prompt_template)
# If template content exists, replace system_prompt with template
if template_content:
system_prompt = template_content
# Prepare messages
messages = []
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt})
# Add image if vision is enabled and model supports it
if enable_vision and is_vision_model and image is not None:
# Convert image tensor to base64
image_url = self._tensor_to_base64(image)
if image_url is None:
error_msg = "Error: Failed to convert image to base64 format."
print(error_msg)
return (error_msg, seed)
messages.append({
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": image_url}
]
})
else:
messages.append({"role": "user", "content": prompt})
# Run inference (silently)
inference_start = time.time()
# Prepare generation parameters
# Set repeat_penalty internally to prevent repetitive output (1.15 is a good balance)
gen_params = {
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"repeat_penalty": 1.15, # Internal setting to reduce repetition
"stop": ["<|im_end|>", "<|endoftext|>", "</s>", "\n\n\n"], # Common stop sequences
}
# Only add seed if it's set (> 0)
if seed > 0:
gen_params["seed"] = seed
response = self.model.create_chat_completion(**gen_params)
# Extract response text
response_text = response["choices"][0]["message"]["content"]
# Remove thinking tags
response_text = self._remove_thinking_tags(response_text)
# Detect and clean excessive repetition
has_repetition, response_text = self._detect_excessive_repetition(response_text)
inference_time = time.time() - inference_start
tokens_generated = response["usage"]["completion_tokens"]
tokens_per_sec = tokens_generated / inference_time if inference_time > 0 else 0
print(f"✓ Done ({inference_time:.1f}s, {tokens_generated} tokens, {tokens_per_sec:.0f}t/s)")
# Always free image-related memory after inference
if image is not None:
self._free_image_memory()
# Unload model if requested
if not keep_model_loaded:
self._free_memory()
return (response_text, seed)
except Exception as e:
import traceback
error_msg = f"⚠️ Inference failed: {str(e)}"
print(error_msg)
# Clean up resources on error (silently)
if image is not None:
self._free_image_memory()
# If error suggests memory/loading issue, unload model completely
error_str = str(e).lower()
if any(keyword in error_str for keyword in ['memory', 'load', 'allocation', 'cuda', 'out of memory']):
self._free_memory()
return (error_msg, seed)