1196 lines
48 KiB
Python
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)
|