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 ... tags and their content"""
cleaned_text = re.sub(r'.*?', '', 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|>", "", "\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)