Files
dseditor-ComfyUI-ListHelper/openrouter_llm.py
T
2025-09-04 15:32:08 +08:00

653 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import requests
import json
import base64
import os
import io
import re
import torch
import random
from PIL import Image, ImageOps
import numpy as np
import math
class OpenRouterLLM:
"""
OpenRouter LLM節點,用於處理OpenRouter的LLM類型
支援圖片和文字輸入輸出,API金鑰管理,動態模型管理
"""
@classmethod
def _load_models(cls):
"""載入模型列表從models.json檔案"""
models_file = os.path.join(os.path.dirname(__file__), "models.json")
try:
with open(models_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get('models', [])
except Exception as e:
print(f"⚠️ 載入models.json失敗: {e}")
# 返回默認模型列表
return [
"google/gemini-2.5-flash-image-preview:free",
"google/gemma-3-27b-it:free",
"qwen/qwen2.5-vl-32b-instruct:free",
"mistralai/mistral-small-3.2-24b-instruct:free",
"meta-llama/llama-4-maverick:free",
"openai/gpt-oss-20b:free",
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
"moonshotai/kimi-dev-72b:free",
"qwen/qwen2.5-vl-72b-instruct:free",
"moonshotai/kimi-k2:free"
]
@classmethod
def _save_models(cls, models_list):
"""保存模型列表到models.json檔案"""
models_file = os.path.join(os.path.dirname(__file__), "models.json")
try:
with open(models_file, 'w', encoding='utf-8') as f:
json.dump({"models": models_list}, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"❌ 保存models.json失敗: {e}")
def _add_custom_model(self, model_name):
"""添加自定義模型到models.json檔案"""
try:
current_models = self._load_models()
# 檢查模型是否已存在
if model_name not in current_models:
current_models.append(model_name)
self._save_models(current_models)
print(f"✅ 已添加新模型: {model_name}")
else:
print(f"ℹ️ 模型已存在: {model_name}")
except Exception as e:
print(f"❌ 添加自定義模型失敗: {e}")
@classmethod
def INPUT_TYPES(cls):
# 從JSON檔案載入模型列表
text_models = cls._load_models()
# 在列表末尾添加"Add Custom Model..."選項
text_models_with_add = text_models + ["Add Custom Model..."]
return {
"required": {
"api_key": ("STRING", {"multiline": False, "default": "", "placeholder": "輸入您的OpenRouter API金鑰"}),
"user_prompt": ("STRING", {"multiline": True, "default": "Please analyze the provided content."}),
"text_model": (text_models_with_add, {"default": text_models_with_add[0]}),
"seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffffffffffff, "step": 1, "tooltip": "隨機種子控制(-1為隨機)。注意:圖像生成模型(如Gemini)不支援seed參數"}),
},
"optional": {
"custom_model": ("STRING", {"multiline": False, "default": "", "placeholder": "輸入自定義模型名稱 (選擇Add Custom Model...時使用)"}),
"system_prompt": ("STRING",),
"enable_resize": ("BOOLEAN", {"default": False, "tooltip": "啟用圖像尺寸調整功能"}),
"target_width": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 8, "tooltip": "需要啟用resize才會生效"}),
"target_height": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 8, "tooltip": "需要啟用resize才會生效"}),
"resize_method": (["bicubic", "lanczos", "bilinear", "nearest"], {"default": "lanczos", "tooltip": "需要啟用resize才會生效"}),
"image_input_1": ("IMAGE",),
"image_input_2": ("IMAGE",),
"image_input_3": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image_output", "text_output")
FUNCTION = "process_llm"
CATEGORY = "ListHelper"
def __init__(self):
self.config_file = "config.json"
def _is_image_generation_model(self, model_name):
"""檢查是否為圖像生成模型"""
image_generation_models = [
"gemini-2.5-flash-image-preview",
"gemini-image", # 可能的其他變體
]
return any(img_model in model_name.lower() for img_model in image_generation_models)
def _get_default_tensor_size(self, enable_resize, target_width, target_height):
"""獲取默認tensor尺寸"""
if enable_resize:
return (1, target_height, target_width, 3)
else:
return (1, 512, 512, 3)
def _create_blank_image_tensor(self, width, height):
"""創建指定尺寸的空白圖像tensor(白色背景)"""
# 創建白色圖像 (1.0 = 白色 in 0-1 range)
blank_tensor = torch.ones(1, height, width, 3, dtype=torch.float32)
return blank_tensor
def _load_config(self):
"""載入配置文件"""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"載入配置文件失敗: {e}")
return {}
def _save_config(self, config):
"""保存配置文件"""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存配置文件失敗: {e}")
def _get_api_key(self, api_key_input):
"""獲取API金鑰"""
config = self._load_config()
# 如果輸入了新的API金鑰,則更新配置
if api_key_input.strip():
config['openrouter_api_key'] = api_key_input.strip()
self._save_config(config)
return api_key_input.strip()
# 否則使用配置文件中的金鑰
return config.get('openrouter_api_key', '')
def _tensor_to_base64(self, tensor, enable_resize=False, target_width=None, target_height=None):
"""將ComfyUI圖像tensor轉換為base64編碼,並可選擇性調整尺寸"""
# tensor shape: [H, W, C] (0-1 range)
if tensor.dim() == 4:
tensor = tensor.squeeze(0) # 移除批次維度
# 轉換為numpy並調整範圍到0-255
numpy_image = (tensor.cpu().numpy() * 255).astype(np.uint8)
# 轉換為PIL圖像
pil_image = Image.fromarray(numpy_image)
# 只有在啟用resize且指定了目標尺寸時,才進行padding處理
if enable_resize and target_width is not None and target_height is not None:
pil_image = self._pad_and_resize_image(pil_image, target_width, target_height)
# 轉換為base64
buffer = io.BytesIO()
pil_image.save(buffer, format='PNG')
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return f"data:image/png;base64,{image_base64}"
def _base64_to_tensor(self, base64_str, enable_resize=False, target_width=None, target_height=None, resize_method="lanczos"):
"""將base64編碼轉換為ComfyUI圖像tensor,並可選擇性調整尺寸"""
try:
# 移除各種可能的前綴
prefixes = [
'data:image/png;base64,',
'data:image/jpeg;base64,',
'data:image/jpg;base64,',
]
original_str = base64_str
for prefix in prefixes:
if base64_str.startswith(prefix):
base64_str = base64_str[len(prefix):]
break
# 清理base64字符串(移除可能的換行符和空格)
base64_str = re.sub(r'[\n\r\s]', '', base64_str)
# 解碼base64
image_bytes = base64.b64decode(base64_str)
# 轉換為PIL圖像
pil_image = Image.open(io.BytesIO(image_bytes))
# 確保是RGB模式
if pil_image.mode != 'RGB':
pil_image = pil_image.convert('RGB')
# 只有在啟用resize且指定了目標尺寸時,才進行縮放處理
if enable_resize and target_width is not None and target_height is not None:
pil_image = self._resize_image(pil_image, target_width, target_height, resize_method)
# 轉換為numpy數組
numpy_image = np.array(pil_image).astype(np.float32) / 255.0
# 轉換為tensor並添加批次維度 [1, H, W, C]
tensor = torch.from_numpy(numpy_image).unsqueeze(0)
return tensor
except Exception as e:
print(f"❌ base64轉tensor失敗: {e}")
# 返回一個較大的默認圖像而不是1x1
return torch.zeros(1, 512, 512, 3)
def _url_to_tensor(self, image_url, enable_resize=False, target_width=None, target_height=None, resize_method="lanczos"):
"""從URL下載圖像並轉換為ComfyUI圖像tensor,並可選擇性調整尺寸"""
try:
# 下載圖像
response = requests.get(image_url, timeout=30)
response.raise_for_status()
# 轉換為PIL圖像
pil_image = Image.open(io.BytesIO(response.content))
# 確保是RGB模式
if pil_image.mode != 'RGB':
pil_image = pil_image.convert('RGB')
# 只有在啟用resize且指定了目標尺寸時,才進行縮放處理
if enable_resize and target_width is not None and target_height is not None:
pil_image = self._resize_image(pil_image, target_width, target_height, resize_method)
# 轉換為numpy數組
numpy_image = np.array(pil_image).astype(np.float32) / 255.0
# 轉換為tensor並添加批次維度 [1, H, W, C]
tensor = torch.from_numpy(numpy_image).unsqueeze(0)
return tensor
except Exception as e:
print(f"❌ 從URL載入圖像失敗: {e}")
# 返回一個較大的默認圖像而不是1x1
return torch.zeros(1, 512, 512, 3)
def _call_openrouter_api(self, api_key, model, messages, seed=None):
"""調用OpenRouter API"""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "",
"X-Title": "ComfyUI"
}
data = {
"model": model,
"messages": messages
}
# 檢查是否為圖像生成模型
is_image_generation_model = self._is_image_generation_model(model)
# 對於圖像生成模型,添加特殊參數
if is_image_generation_model:
# 確保包含image輸出模式
data["modalities"] = ["image", "text"]
# 不設置max_tokens,讓模型有足夠空間生成圖像
# 只對非圖像生成模型設置seed參數
if seed is not None and not is_image_generation_model:
data["seed"] = seed
try:
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers=headers,
data=json.dumps(data),
timeout=60
)
if response.status_code == 200:
result = response.json()
return result
elif response.status_code == 429:
# Rate limit錯誤
try:
error_data = response.json()
return {"error": {"code": 429, "message": error_data.get("error", {}).get("message", "Rate limit exceeded")}}
except:
return {"error": {"code": 429, "message": "Rate limit exceeded"}}
else:
try:
error_data = response.json()
return {"error": error_data.get("error", {"message": f"HTTP {response.status_code}"})}
except:
return {"error": {"message": f"HTTP {response.status_code}: {response.text[:200]}"}}
except Exception as e:
print(f"❌ API調用異常: {e}")
return None
def _pad_and_resize_image(self, pil_image, target_width, target_height):
"""使用比例縮放+padding的方式處理圖像到指定尺寸"""
try:
original_width, original_height = pil_image.size
target_ratio = target_width / target_height
original_ratio = original_width / original_height
# 先根據比例關係決定如何縮放
if original_ratio > target_ratio:
# 原圖較寬,以寬度為準縮放
scale_ratio = target_width / original_width
new_width = target_width
new_height = int(original_height * scale_ratio)
else:
# 原圖較高,以高度為準縮放
scale_ratio = target_height / original_height
new_width = int(original_width * scale_ratio)
new_height = target_height
# 縮放圖像
pil_image = pil_image.resize((new_width, new_height), Image.LANCZOS)
# 計算需要的padding
pad_width = target_width - new_width
pad_height = target_height - new_height
# 應用padding(居中)
if pad_width > 0 or pad_height > 0:
left_pad = pad_width // 2
right_pad = pad_width - left_pad
top_pad = pad_height // 2
bottom_pad = pad_height - top_pad
# 使用黑色填充padding區域
pil_image = ImageOps.expand(pil_image, (left_pad, top_pad, right_pad, bottom_pad), fill=(0, 0, 0))
# 檢查是否需要進一步縮放(如果任一邊超過1536)
current_width, current_height = pil_image.size
if current_width > 1536 or current_height > 1536:
# 保持比例縮放到1536以內
final_scale_ratio = min(1536 / current_width, 1536 / current_height)
final_width = int(current_width * final_scale_ratio)
final_height = int(current_height * final_scale_ratio)
pil_image = pil_image.resize((final_width, final_height), Image.LANCZOS)
return pil_image
except Exception as e:
print(f"❌ 圖像padding處理失敗: {e}")
return pil_image
def _resize_image(self, pil_image, target_width, target_height, resize_method="lanczos"):
"""使用指定方法縮放圖像到目標尺寸"""
try:
# 選擇縮放方法
method_map = {
"bicubic": Image.BICUBIC,
"lanczos": Image.LANCZOS,
"bilinear": Image.BILINEAR,
"nearest": Image.NEAREST
}
resize_filter = method_map.get(resize_method, Image.LANCZOS)
return pil_image.resize((target_width, target_height), resize_filter)
except Exception as e:
print(f"❌ 圖像縮放失敗: {e}")
return pil_image
def process_llm(self, api_key, user_prompt, text_model, seed=-1, custom_model="", system_prompt=None,
enable_resize=False, target_width=512, target_height=512, resize_method="lanczos",
image_input_1=None, image_input_2=None, image_input_3=None):
"""處理LLM請求"""
# 獲取API金鑰
actual_api_key = self._get_api_key(api_key)
if not actual_api_key:
# 使用默認尺寸512x512當enable_resize關閉時
default_height = target_height if enable_resize else 512
default_width = target_width if enable_resize else 512
return (torch.zeros(*self._get_default_tensor_size(enable_resize, target_width, target_height)), "❌ 錯誤: 請提供OpenRouter API金鑰")
# 處理種子設定 - Control After Generate支援
if seed == -1:
# 隨機種子
actual_seed = random.randint(0, 2147483647)
else:
# 固定種子
actual_seed = seed
# 處理模型選擇
if text_model == "Add Custom Model...":
if not custom_model or not custom_model.strip():
return (torch.zeros(1, target_height, target_width, 3), "❌ 請在custom_model欄位輸入自定義模型名稱")
# 使用自定義模型
selected_model = custom_model.strip()
# 將新模型添加到models.json中
self._add_custom_model(selected_model)
else:
# 使用選擇的預設模型
selected_model = text_model
# 準備消息內容
messages = []
# 添加系統提示(如果有的話)
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt.strip()})
# 構建用戶消息
user_content = []
# 添加文字內容
if user_prompt and user_prompt.strip():
user_content.append({
"type": "text",
"text": user_prompt.strip()
})
# 檢查是否有圖像輸入
original_has_images = any([image_input_1 is not None, image_input_2 is not None, image_input_3 is not None])
# 如果enable_resize開啟且沒有圖像輸入,自動生成空白圖像給image_input_1
auto_generated_blank = False
if enable_resize and not original_has_images:
image_input_1 = self._create_blank_image_tensor(target_width, target_height)
auto_generated_blank = True
# 重新檢查圖像輸入狀況(包含自動生成的空白圖像)
has_images = any([image_input_1 is not None, image_input_2 is not None, image_input_3 is not None])
# 確定使用的模型
if has_images:
# 有圖像輸入時,強制使用gemini-2.5-flash-image-preview:free
actual_model = "google/gemini-2.5-flash-image-preview:free"
else:
# 無圖像輸入時,使用用戶選擇的模型
actual_model = selected_model
# 檢查是否使用圖像生成模型(無論是否有圖像輸入)
is_image_model = self._is_image_generation_model(actual_model)
if has_images:
# 有圖像輸入的情況
if auto_generated_blank:
# 使用自動生成的空白圖像時的特殊處理
if not user_prompt or not user_prompt.strip():
combined_text = "Please take a look at the size of image 1 and give a picture of a beautiful landscape with the same dimensions."
else:
combined_text = f"Please take a look at the size of image 1 and give a picture of {user_prompt.strip()}"
combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。"
else:
# 有實際圖像輸入時的處理
if not user_prompt or not user_prompt.strip():
combined_text = "請分析這些圖像並生成一個相關的新圖像。IMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。"
else:
combined_text = user_prompt.strip()
if "生成" in combined_text or "創建" in combined_text or "製作" in combined_text:
combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。"
else:
combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。"
# 重新添加文字內容
user_content = [{
"type": "text",
"text": combined_text
}]
# 添加圖像到用戶消息(帶padding處理)
for i, image_input in enumerate([image_input_1, image_input_2, image_input_3], 1):
if image_input is not None:
try:
base64_image = self._tensor_to_base64(image_input, enable_resize, target_width, target_height)
user_content.append({
"type": "image_url",
"image_url": {
"url": base64_image
}
})
except Exception as e:
print(f"❌ 處理圖像{i}失敗: {e}")
elif is_image_model:
# 純文字模式但使用圖像生成模型
combined_text = user_prompt.strip() if user_prompt else ""
if combined_text and ("生成" in combined_text or "創建" in combined_text or "製作" in combined_text or "畫" in combined_text or "繪製" in combined_text):
combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請生成並返回相應的圖像。"
elif not combined_text:
combined_text = "IMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請生成一張圖像。"
else:
combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image."
# 添加文字內容
user_content.append({
"type": "text",
"text": combined_text
})
# 添加用戶消息
if len(user_content) > 1:
# 多媒體內容(包含圖像)
messages.append({
"role": "user",
"content": user_content
})
elif user_content:
# 純文字內容
messages.append({
"role": "user",
"content": user_content[0]["text"]
})
else:
# 空內容
messages.append({
"role": "user",
"content": "請分析提供的內容。"
})
# 調用API
response = self._call_openrouter_api(actual_api_key, actual_model, messages, actual_seed)
if not response:
return (torch.zeros(1, target_height, target_width, 3), "❌ API調用失敗")
# 檢查rate limit錯誤
if 'error' in response:
error_info = response['error']
if isinstance(error_info, dict) and 'code' in error_info:
if error_info['code'] == 429 or 'rate_limit' in str(error_info).lower():
error_msg = f"⚠️ Rate Limit達到: {error_info.get('message', '請稍後再試')}"
return (torch.zeros(1, target_height, target_width, 3), error_msg)
else:
error_msg = f"❌ API錯誤: {error_info.get('message', str(error_info))}"
return (torch.zeros(1, target_height, target_width, 3), error_msg)
else:
error_msg = f"❌ API錯誤: {str(error_info)}"
return (torch.zeros(1, target_height, target_width, 3), error_msg)
if 'choices' not in response or not response['choices']:
return (torch.zeros(1, target_height, target_width, 3), "❌ API回應格式異常")
# 獲取回應訊息
message = response['choices'][0]['message']
response_content = message.get('content', '')
# 檢查回應是否包含圖像數據
output_image = torch.zeros(1, target_height, target_width, 3) # 預設指定尺寸的空圖像
output_text = response_content
found_image = False
# 首先檢查message.images字段
if 'images' in message and message['images']:
image_data = message['images'][0]
try:
if isinstance(image_data, str):
if image_data.startswith('data:image/'):
output_image = self._base64_to_tensor(image_data, enable_resize, target_width, target_height, resize_method)
found_image = True
elif image_data.startswith('http'):
output_image = self._url_to_tensor(image_data, enable_resize, target_width, target_height, resize_method)
found_image = True
else:
# 可能是純 base64,添加 data URL 前綴
full_data_url = f"data:image/png;base64,{image_data}"
output_image = self._base64_to_tensor(full_data_url, enable_resize, target_width, target_height, resize_method)
found_image = True
elif isinstance(image_data, dict):
# 處理嵌套的 image_url 字典格式
if 'type' in image_data and image_data['type'] == 'image_url' and 'image_url' in image_data:
nested_image_url = image_data['image_url']
if isinstance(nested_image_url, dict) and 'url' in nested_image_url:
image_url_value = nested_image_url['url']
if str(image_url_value).startswith('data:image/'):
output_image = self._base64_to_tensor(str(image_url_value), enable_resize, target_width, target_height, resize_method)
else:
output_image = self._url_to_tensor(str(image_url_value), enable_resize, target_width, target_height, resize_method)
found_image = True
# 處理直接包含 url 的格式
elif 'url' in image_data:
url_value = str(image_data['url'])
if url_value.startswith('data:image/'):
output_image = self._base64_to_tensor(url_value, enable_resize, target_width, target_height, resize_method)
else:
output_image = self._url_to_tensor(url_value, enable_resize, target_width, target_height, resize_method)
found_image = True
# 處理直接包含 data 的格式
elif 'data' in image_data:
output_image = self._base64_to_tensor(str(image_data['data']), enable_resize, target_width, target_height, resize_method)
found_image = True
except Exception as e:
print(f"❌ 處理圖像失敗: {e}")
found_image = False
# 如果在message.images中沒有找到圖像,檢查content中的嵌入圖像
if not found_image and is_image_model:
# 檢查URL和base64格式的圖像
url_patterns = [
r'https?://[^\s\)]+\.(?:png|jpg|jpeg|gif|webp)',
r'!\[.*?\]\((https?://[^\s\)]+\.(?:png|jpg|jpeg|gif|webp))\)',
r'<img[^>]+src=["\']([^"\']+)["\'][^>]*>',
]
base64_patterns = [
r'data:image/png;base64,([A-Za-z0-9+/=\n\r]+)',
r'data:image/jpeg;base64,([A-Za-z0-9+/=\n\r]+)',
r'data:image/jpg;base64,([A-Za-z0-9+/=\n\r]+)',
]
# 檢查URL格式
for pattern in url_patterns:
matches = re.findall(pattern, response_content, re.DOTALL)
if matches:
try:
output_image = self._url_to_tensor(matches[0], enable_resize, target_width, target_height, resize_method)
found_image = True
output_text = re.sub(pattern, "[Generated Image]", response_content, flags=re.DOTALL)
break
except Exception as e:
continue
# 檢查base64格式
if not found_image:
for pattern in base64_patterns:
matches = re.findall(pattern, response_content, re.DOTALL)
if matches:
try:
clean_base64 = re.sub(r'[\n\r\s]', '', matches[0])
base64_image = f"data:image/png;base64,{clean_base64}"
output_image = self._base64_to_tensor(base64_image, enable_resize, target_width, target_height, resize_method)
found_image = True
output_text = re.sub(pattern, "[Generated Image]", response_content, flags=re.DOTALL)
break
except Exception as e:
continue
return (output_image, output_text)