657 lines
22 KiB
Python
657 lines
22 KiB
Python
from typing import Optional
|
|
from typing import Tuple
|
|
import os
|
|
import time
|
|
import requests
|
|
import importlib
|
|
|
|
BASE_URL = "https://api-inference.modelscope.cn/v1"
|
|
|
|
MODELSCOPE_API_KEY = os.getenv("MODELSCOPE_API_KEY") or os.getenv("MODELSCOPE_ACCESS_TOKEN")
|
|
|
|
DEFAULT_SYSTEM_PROMPT = "You are a helpful and harmless assistant. Answer concisely and helpfully."
|
|
|
|
|
|
class ModelScopeBase:
|
|
"""Base class for ModelScope nodes with common functionality."""
|
|
|
|
def _resolve_key(self, api_key: Optional[str]) -> str:
|
|
"""Resolve API key from input or environment variables."""
|
|
key = api_key or MODELSCOPE_API_KEY
|
|
if not key:
|
|
raise ValueError(
|
|
"API key missing. Provide 'api_key' or set MODELSCOPE_API_KEY / MODELSCOPE_ACCESS_TOKEN."
|
|
)
|
|
return key
|
|
|
|
def _sanitize_text(self, text: str) -> str:
|
|
"""Sanitize text for ComfyUI showText node compatibility.
|
|
|
|
Args:
|
|
text: Raw text string
|
|
|
|
Returns:
|
|
str: Sanitized text safe for display
|
|
"""
|
|
if not isinstance(text, str):
|
|
text = str(text)
|
|
|
|
# Remove or replace problematic characters
|
|
import re
|
|
|
|
# Remove null bytes and other control characters except common ones
|
|
text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
|
|
|
|
# Normalize whitespace
|
|
text = re.sub(r'\s+', ' ', text).strip()
|
|
|
|
# Ensure text is not empty
|
|
if not text:
|
|
text = "[Empty Response]"
|
|
|
|
# Limit length if extremely long (optional safeguard)
|
|
max_length = 10000
|
|
if len(text) > max_length:
|
|
text = text[:max_length] + "...[truncated]"
|
|
|
|
# Ensure proper encoding
|
|
try:
|
|
text.encode('utf-8').decode('utf-8')
|
|
except UnicodeError:
|
|
# Fallback: replace problematic characters
|
|
text = text.encode('utf-8', errors='replace').decode('utf-8')
|
|
|
|
return text
|
|
|
|
def _get_pil_image_module(self):
|
|
"""Get PIL Image module with lazy import."""
|
|
try:
|
|
return importlib.import_module("PIL.Image")
|
|
except Exception:
|
|
raise RuntimeError("Pillow is required. Install with: pip install pillow")
|
|
|
|
def _download_image_from_url(self, url: str, headers: dict) -> object:
|
|
"""Download image from URL and convert to ComfyUI IMAGE format.
|
|
|
|
Args:
|
|
url: Image URL to download
|
|
headers: HTTP headers for authentication
|
|
|
|
Returns:
|
|
torch.Tensor: Image tensor in ComfyUI format [B, H, W, C]
|
|
|
|
Raises:
|
|
RuntimeError: If download or conversion fails
|
|
"""
|
|
try:
|
|
import torch
|
|
import numpy as np
|
|
|
|
# Download image
|
|
response = requests.get(url, headers=headers, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
# Get PIL Image module
|
|
PIL_Image = self._get_pil_image_module()
|
|
|
|
# Open image from bytes
|
|
from io import BytesIO
|
|
image = PIL_Image.open(BytesIO(response.content))
|
|
|
|
# Convert to RGB if necessary
|
|
if image.mode != 'RGB':
|
|
image = image.convert('RGB')
|
|
|
|
# Convert PIL to numpy array
|
|
image_np = np.array(image).astype(np.float32) / 255.0
|
|
|
|
# Convert to torch tensor with ComfyUI format [B, H, W, C]
|
|
image_tensor = torch.from_numpy(image_np)[None,]
|
|
|
|
return image_tensor
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to download image: {e}") from e
|
|
|
|
|
|
class ModelScopeChatBase(ModelScopeBase):
|
|
"""Base class for chat-based ModelScope nodes."""
|
|
|
|
def _extract_text(self, data: dict) -> str:
|
|
"""Extract text content from chat completion response.
|
|
Tries to read choices[0].message.content (string or list) and falls back to str(data).
|
|
"""
|
|
try:
|
|
content = data["choices"][0]["message"]["content"]
|
|
if isinstance(content, str):
|
|
return self._sanitize_text(content)
|
|
if isinstance(content, list) and content:
|
|
part = content[0]
|
|
if isinstance(part, str):
|
|
return self._sanitize_text(part)
|
|
if isinstance(part, dict):
|
|
txt = part.get("text") or part.get("content")
|
|
if txt:
|
|
return self._sanitize_text(txt)
|
|
except Exception:
|
|
pass
|
|
# Fallback to string representation
|
|
try:
|
|
return self._sanitize_text(str(data))
|
|
except Exception:
|
|
return "[Error: Unable to extract response]"
|
|
|
|
|
|
class ModelScopeLLM(ModelScopeChatBase):
|
|
"""ModelScope Text Chat (non-streaming) node."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model_id": (
|
|
"STRING",
|
|
{
|
|
"default": "Qwen/Qwen3-235B-A22B",
|
|
"placeholder": "Model ID",
|
|
"tooltip": "The ModelScope model ID to use.",
|
|
},
|
|
),
|
|
"prompt": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"placeholder": "User prompt",
|
|
"tooltip": "The user prompt text to send to the model.",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"system_prompt": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"placeholder": "System Prompt",
|
|
"tooltip": "Optional system prompt to prime the assistant.",
|
|
},
|
|
),
|
|
"api_key": (
|
|
"STRING",
|
|
{
|
|
"placeholder": "API Key",
|
|
"tooltip": "ModelScope API key; if empty, read from MODELSCOPE_API_KEY env var.",
|
|
},
|
|
),
|
|
"request_timeout": (
|
|
"INT",
|
|
{
|
|
"default": 30,
|
|
"min": 10,
|
|
"max": 300,
|
|
"step": 10,
|
|
"tooltip": "Request timeout in seconds. Increase for larger models or slow networks.",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("response",)
|
|
FUNCTION = "chat_completion"
|
|
CATEGORY = "ModelScope"
|
|
DESCRIPTION = "Generate a chat response using ModelScope LLM."
|
|
OUTPUT_TOOLTIPS = ("ModelScope text response.",)
|
|
|
|
def chat_completion(
|
|
self,
|
|
model_id: str,
|
|
prompt: str,
|
|
system_prompt: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
request_timeout: int = 30,
|
|
) -> Tuple[str]:
|
|
"""Execute text generation using ModelScope LLM.
|
|
|
|
Args:
|
|
model_id: ModelScope LLM model identifier
|
|
prompt: User text prompt for the model
|
|
system_prompt: Optional system prompt for assistant behavior
|
|
api_key: Optional API key override
|
|
request_timeout: Request timeout in seconds
|
|
|
|
Returns:
|
|
tuple: Single-element tuple with LLM response text
|
|
|
|
Raises:
|
|
ValueError: If inputs are invalid
|
|
RuntimeError: If API call or processing fails
|
|
"""
|
|
# Validate inputs
|
|
if not prompt.strip():
|
|
raise ValueError("Prompt cannot be empty")
|
|
|
|
key = self._resolve_key(api_key)
|
|
system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
|
|
url = f"{BASE_URL}/chat/completions"
|
|
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
|
payload = {
|
|
"model": model_id,
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": system_prompt,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": prompt,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
"stream": False,
|
|
"enable_thinking": False,
|
|
}
|
|
|
|
start_time = time.time()
|
|
try:
|
|
resp = requests.post(
|
|
url,
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=request_timeout,
|
|
verify=False, # Temporary SSL workaround
|
|
)
|
|
resp.raise_for_status()
|
|
except requests.Timeout as e:
|
|
elapsed = time.time() - start_time
|
|
raise RuntimeError(
|
|
f"ModelScope LLM request timed out after {elapsed:.1f}s. "
|
|
f"Try increasing request_timeout (current: {request_timeout}s) or using a smaller model."
|
|
) from e
|
|
except requests.RequestException as e:
|
|
raise RuntimeError(f"Network error calling ModelScope LLM: {e}") from e
|
|
|
|
try:
|
|
data = resp.json()
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to parse LLM API response: {e}") from e
|
|
|
|
try:
|
|
text = self._extract_text(data)
|
|
if not text.strip():
|
|
raise RuntimeError("LLM returned empty response")
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to extract text from LLM response: {e}") from e
|
|
|
|
elapsed = time.time() - start_time
|
|
print(f"LLM inference completed in {elapsed:.1f}s")
|
|
|
|
return (text,)
|
|
|
|
|
|
class ModelScopeVLM(ModelScopeChatBase):
|
|
"""ModelScope Visual Language Model node for image-text conversations.
|
|
|
|
Supports analyzing images with text prompts using ModelScope VLM models.
|
|
Handles various image input formats and provides robust error handling.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""Define input types and validation rules for VLM processing.
|
|
|
|
Returns:
|
|
dict: Input type definitions with validation parameters
|
|
"""
|
|
return {
|
|
"required": {
|
|
"model_id": (
|
|
"STRING",
|
|
{
|
|
"default": "Qwen/QVQ-72B-Preview",
|
|
"placeholder": "Model ID",
|
|
"tooltip": "ModelScope VLM model ID.",
|
|
},
|
|
),
|
|
"image_url": (
|
|
"STRING",
|
|
{
|
|
"placeholder": "https://example.com/image.jpg",
|
|
"tooltip": "URL of the image to analyze. Must be publicly accessible.",
|
|
},
|
|
),
|
|
"prompt": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"placeholder": "What do you see in this image?",
|
|
"tooltip": "Question or instruction about the image.",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"system_prompt": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"placeholder": "You are a helpful vision assistant...",
|
|
"tooltip": "Optional system prompt to guide the assistant's behavior.",
|
|
},
|
|
),
|
|
"api_key": (
|
|
"STRING",
|
|
{
|
|
"placeholder": "API Key",
|
|
"tooltip": "ModelScope API key. If empty, reads MODELSCOPE_API_KEY or MODELSCOPE_ACCESS_TOKEN from environment.",
|
|
},
|
|
),
|
|
"request_timeout": (
|
|
"INT",
|
|
{
|
|
"default": 120,
|
|
"min": 30,
|
|
"max": 300,
|
|
"step": 10,
|
|
"tooltip": "Request timeout in seconds. VLM inference can be slow, recommend 120+ seconds.",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("response",)
|
|
FUNCTION = "visual_chat"
|
|
CATEGORY = "ModelScope"
|
|
DESCRIPTION = "Analyze images with text using ModelScope Vision-Language Models."
|
|
OUTPUT_TOOLTIPS = ("VLM response describing or answering about the image.",)
|
|
|
|
def visual_chat(
|
|
self,
|
|
model_id: str,
|
|
image_url: str,
|
|
prompt: str,
|
|
system_prompt: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
request_timeout: int = 120,
|
|
) -> Tuple[str]:
|
|
"""Execute vision-language model inference on image and text.
|
|
|
|
Args:
|
|
model_id: ModelScope VLM model identifier
|
|
image_url: URL of image to analyze
|
|
prompt: Text prompt/question about the image
|
|
system_prompt: Optional system prompt for assistant behavior
|
|
api_key: Optional API key override
|
|
request_timeout: Request timeout in seconds
|
|
|
|
Returns:
|
|
tuple: Single-element tuple with VLM response text
|
|
|
|
Raises:
|
|
ValueError: If inputs are invalid
|
|
RuntimeError: If API call or processing fails
|
|
"""
|
|
# Validate inputs
|
|
if not image_url.strip():
|
|
raise ValueError("Image URL cannot be empty")
|
|
if not prompt.strip():
|
|
raise ValueError("Prompt cannot be empty")
|
|
|
|
# Validate image URL format
|
|
if not (image_url.startswith('http://') or image_url.startswith('https://')):
|
|
raise ValueError("Image URL must be a valid HTTP/HTTPS URL")
|
|
|
|
key = self._resolve_key(api_key)
|
|
system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
|
|
|
|
url = f"{BASE_URL}/chat/completions"
|
|
headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
# Construct VLM message payload with image and text
|
|
payload = {
|
|
"model": model_id,
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": system_prompt,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": image_url,
|
|
},
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": prompt,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
"stream": False,
|
|
"enable_thinking": False,
|
|
}
|
|
|
|
start_time = time.time()
|
|
try:
|
|
# Use longer timeout for VLM inference
|
|
resp = requests.post(
|
|
url,
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=request_timeout,
|
|
verify=False, # Temporary SSL workaround
|
|
)
|
|
resp.raise_for_status()
|
|
except requests.Timeout as e:
|
|
elapsed = time.time() - start_time
|
|
raise RuntimeError(
|
|
f"ModelScope VLM request timed out after {elapsed:.1f}s. "
|
|
f"Try increasing request_timeout (current: {request_timeout}s) or using a smaller model."
|
|
) from e
|
|
except requests.RequestException as e:
|
|
raise RuntimeError(f"Network error calling ModelScope VLM: {e}") from e
|
|
|
|
try:
|
|
data = resp.json()
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to parse VLM API response: {e}") from e
|
|
|
|
try:
|
|
text = self._extract_text(data)
|
|
if not text.strip():
|
|
raise RuntimeError("VLM returned empty response")
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to extract text from VLM response: {e}") from e
|
|
|
|
elapsed = time.time() - start_time
|
|
print(f"VLM inference completed in {elapsed:.1f}s")
|
|
|
|
return (text,)
|
|
|
|
|
|
class ModelScopeImageGenerator(ModelScopeBase):
|
|
"""ModelScope Image Generation node."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model_id": (
|
|
"STRING",
|
|
{
|
|
"default": "Qwen/Qwen-Image-Edit",
|
|
"placeholder": "Model ID",
|
|
"tooltip": "ModelScope model ID for image generation.",
|
|
},
|
|
),
|
|
"prompt": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"placeholder": "A mysterious girl walking down the corridor.",
|
|
"tooltip": "Text prompt for image generation.",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"negative_prompt": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"placeholder": "lowres, bad anatomy...",
|
|
"tooltip": "Negative prompt to avoid unwanted elements.",
|
|
},
|
|
),
|
|
"num_inference_steps": (
|
|
"INT",
|
|
{
|
|
"default": 30,
|
|
"min": 1,
|
|
"max": 100,
|
|
"step": 1,
|
|
"tooltip": "Number of denoising steps (higher = better quality, slower).",
|
|
},
|
|
),
|
|
"guidance_scale": (
|
|
"FLOAT",
|
|
{
|
|
"default": 3.5,
|
|
"min": 1.0,
|
|
"max": 20.0,
|
|
"step": 0.1,
|
|
"tooltip": "How closely to follow the prompt (higher = more adherent).",
|
|
},
|
|
),
|
|
"height": (
|
|
"INT",
|
|
{
|
|
"default": 1024,
|
|
"min": 64,
|
|
"max": 2048,
|
|
"step": 8,
|
|
"tooltip": "Generated image height in pixels.",
|
|
},
|
|
),
|
|
"width": (
|
|
"INT",
|
|
{
|
|
"default": 1024,
|
|
"min": 64,
|
|
"max": 2048,
|
|
"step": 8,
|
|
"tooltip": "Generated image width in pixels.",
|
|
},
|
|
),
|
|
"api_key": (
|
|
"STRING",
|
|
{
|
|
"placeholder": "API Key",
|
|
"tooltip": "ModelScope API key. If empty, reads from MODELSCOPE_API_KEY env var.",
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("image",)
|
|
FUNCTION = "image_generation"
|
|
CATEGORY = "ModelScope"
|
|
DESCRIPTION = "Generate images using ModelScope text-to-image models."
|
|
OUTPUT_TOOLTIPS = ("Generated image as ComfyUI IMAGE tensor.",)
|
|
|
|
def image_generation(
|
|
self,
|
|
model_id: str,
|
|
prompt: str,
|
|
api_key: Optional[str] = None,
|
|
negative_prompt: Optional[str] = None,
|
|
num_inference_steps: int = 30,
|
|
guidance_scale: float = 3.5,
|
|
height: int = 1024,
|
|
width: int = 1024,
|
|
) -> Tuple[object]:
|
|
"""Generate image using ModelScope API.
|
|
|
|
Args:
|
|
model_id: ModelScope model identifier
|
|
prompt: Text description of desired image
|
|
api_key: Optional API key override
|
|
negative_prompt: Elements to avoid in generation
|
|
num_inference_steps: Number of denoising steps
|
|
guidance_scale: Adherence to prompt
|
|
height: Image height in pixels
|
|
width: Image width in pixels
|
|
|
|
Returns:
|
|
tuple: Single-element tuple containing image tensor
|
|
|
|
Raises:
|
|
RuntimeError: If API call or image processing fails
|
|
"""
|
|
key = self._resolve_key(api_key)
|
|
url = f"{BASE_URL}/images/generations"
|
|
headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
payload = {
|
|
"model": model_id,
|
|
"prompt": prompt,
|
|
"negative_prompt": negative_prompt,
|
|
"num_inference_steps": num_inference_steps,
|
|
"guidance_scale": guidance_scale,
|
|
"size": f"{width}x{height}",
|
|
}
|
|
|
|
try:
|
|
resp = requests.post(url, headers=headers, json=payload, timeout=60, verify=False)
|
|
resp.raise_for_status()
|
|
except requests.RequestException as e:
|
|
raise RuntimeError(f"Network error calling ModelScope Image API: {e}") from e
|
|
|
|
try:
|
|
data = resp.json()
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to parse API response: {e}") from e
|
|
|
|
# Handle direct image URL response
|
|
if data.get("images"):
|
|
first_image = data["images"][0]
|
|
if isinstance(first_image, dict) and first_image.get("url"):
|
|
return (self._download_image_from_url(first_image["url"], headers),)
|
|
|
|
raise RuntimeError(f"Unable to locate generated image in response: {data}")
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"ModelScopeLLM": ModelScopeLLM,
|
|
"ModelScopeVLM": ModelScopeVLM,
|
|
"ModelScopeImageGenerator": ModelScopeImageGenerator,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"ModelScopeLLM": "ModelScope LLM",
|
|
"ModelScopeVLM": "ModelScope VLM",
|
|
"ModelScopeImageGenerator": "ModelScope Image Generator",
|
|
} |