391 lines
12 KiB
Python
391 lines
12 KiB
Python
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
import requests
|
|
from PIL import Image
|
|
from torch import Tensor
|
|
|
|
# Latest Claude models (as of 2025)
|
|
# Note: All these models support "Extended thinking" capability for deep reasoning
|
|
claude_models = [
|
|
"claude-opus-4-5", # Premium model combining maximum intelligence with practical performance
|
|
"claude-sonnet-4-5", # Our best model for real-world agents and coding
|
|
"claude-haiku-4-5", # Hybrid model, capable of near-instant responses and extended thinking
|
|
]
|
|
|
|
# Google Gemini models (as of 2025)
|
|
# Gemini 3 Series only - Latest generation
|
|
gemini_models = [
|
|
"gemini-3-pro-preview", # Most intelligent, best for complex reasoning and analysis (1M context)
|
|
"gemini-3-flash-preview", # Most balanced for speed, scale, and frontier intelligence (1M context)
|
|
]
|
|
|
|
# OpenAI GPT models (as of 2025)
|
|
# GPT-5 Series only - Latest generation
|
|
openai_models = [
|
|
"gpt-5.2", # The best model for coding and agentic tasks across industries
|
|
"gpt-5.2-pro", # Version of GPT-5.2 that produces smarter and more precise responses
|
|
"gpt-5", # Intelligent reasoning model for coding and agentic tasks
|
|
"gpt-5-pro", # Version of GPT-5 that produces smarter and more precise responses
|
|
"gpt-5-mini", # Faster, cost-efficient version of GPT-5 for well-defined tasks
|
|
"gpt-5-nano", # Fastest, most cost-efficient version of GPT-5
|
|
]
|
|
|
|
# All available models
|
|
all_models = claude_models + gemini_models + openai_models
|
|
|
|
|
|
# Utility functions
|
|
def numpy2pil(numpy_image: np.ndarray, mode=None) -> Image.Image:
|
|
if numpy_image.dtype == np.float32 or numpy_image.dtype == np.float64:
|
|
numpy_image = np.clip(numpy_image, 0, 1)
|
|
numpy_image = (numpy_image * 255).astype(np.uint8)
|
|
elif numpy_image.dtype != np.uint8:
|
|
numpy_image = numpy_image.astype(np.uint8)
|
|
|
|
if len(numpy_image.shape) == 3 and numpy_image.shape[2] == 1:
|
|
numpy_image = numpy_image.squeeze(axis=2)
|
|
|
|
if mode:
|
|
return Image.fromarray(numpy_image, mode=mode)
|
|
elif len(numpy_image.shape) == 2:
|
|
return Image.fromarray(numpy_image, mode="L")
|
|
elif len(numpy_image.shape) == 3:
|
|
if numpy_image.shape[2] == 3:
|
|
return Image.fromarray(numpy_image, mode="RGB")
|
|
elif numpy_image.shape[2] == 4:
|
|
return Image.fromarray(numpy_image, mode="RGBA")
|
|
|
|
return Image.fromarray(numpy_image)
|
|
|
|
|
|
def tensor2pil(image: Tensor, mode=None):
|
|
return numpy2pil(image.cpu().numpy().squeeze(), mode=mode)
|
|
|
|
|
|
def pil2base64(image: Image.Image):
|
|
buffered = io.BytesIO()
|
|
image.save(buffered, format="PNG")
|
|
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
|
return img_str
|
|
|
|
|
|
def call_claude_api(
|
|
api_key: str,
|
|
model: str,
|
|
prompt: str,
|
|
system_prompt: str,
|
|
image: Optional[Tensor],
|
|
max_tokens: int,
|
|
temperature: float,
|
|
seed: int = -1,
|
|
timeout: int = 500,
|
|
endpoint: str = "https://api.anthropic.com",
|
|
):
|
|
"""Single function to call Claude API with text and optional image
|
|
|
|
Note: Claude API does not natively support seed parameter for reproducible outputs.
|
|
The seed parameter is included for UI consistency but does not affect Claude's output.
|
|
"""
|
|
|
|
# Build message content
|
|
content = []
|
|
|
|
# Add image if provided
|
|
if image is not None:
|
|
if len(image.shape) == 4: # Batch of images - just use first one
|
|
pil_image = tensor2pil(image[0])
|
|
else:
|
|
pil_image = tensor2pil(image)
|
|
|
|
image_base64 = pil2base64(pil_image)
|
|
content.append(
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": image_base64,
|
|
},
|
|
}
|
|
)
|
|
|
|
# Add text
|
|
content.append({"type": "text", "text": prompt})
|
|
|
|
# Build request
|
|
url = f"{endpoint}/v1/messages"
|
|
data = {
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": content}],
|
|
"max_tokens": max_tokens,
|
|
"temperature": temperature,
|
|
}
|
|
|
|
# Add system prompt if provided
|
|
if system_prompt and system_prompt.strip():
|
|
data["system"] = system_prompt
|
|
|
|
headers = {
|
|
"x-api-key": api_key,
|
|
"anthropic-version": "2023-06-01",
|
|
"content-type": "application/json",
|
|
}
|
|
|
|
# Make request
|
|
response = requests.post(url, json=data, headers=headers, timeout=timeout)
|
|
|
|
# Handle errors
|
|
if response.status_code != 200:
|
|
error_msg = f"API request failed with status {response.status_code}"
|
|
try:
|
|
error_data = response.json()
|
|
if error_data.get("error"):
|
|
error_msg = error_data["error"].get("message", error_msg)
|
|
except Exception:
|
|
pass
|
|
raise Exception(error_msg)
|
|
|
|
response_data = response.json()
|
|
|
|
if response_data.get("error"):
|
|
raise Exception(response_data.get("error").get("message", "Unknown error"))
|
|
|
|
# Extract text response and token usage
|
|
text = response_data["content"][0]["text"]
|
|
usage = response_data.get("usage", {})
|
|
token_usage = {
|
|
"input_tokens": usage.get("input_tokens", 0),
|
|
"output_tokens": usage.get("output_tokens", 0),
|
|
}
|
|
|
|
return text, token_usage
|
|
|
|
|
|
def call_gemini_api(
|
|
api_key: str,
|
|
model: str,
|
|
prompt: str,
|
|
system_prompt: str,
|
|
image: Optional[Tensor],
|
|
max_tokens: int,
|
|
temperature: float,
|
|
seed: int = -1,
|
|
timeout: int = 500,
|
|
):
|
|
"""Single function to call Gemini API with text and optional image
|
|
|
|
Supports seed parameter for reproducible outputs when seed != -1.
|
|
"""
|
|
|
|
# Build contents array
|
|
contents = []
|
|
|
|
# Add system instruction if provided
|
|
system_instruction = None
|
|
if system_prompt and system_prompt.strip():
|
|
system_instruction = {"parts": [{"text": system_prompt}]}
|
|
|
|
# Build content parts
|
|
parts = []
|
|
|
|
# Add image if provided
|
|
if image is not None:
|
|
if len(image.shape) == 4: # Batch of images - just use first one
|
|
pil_image = tensor2pil(image[0])
|
|
else:
|
|
pil_image = tensor2pil(image)
|
|
|
|
image_base64 = pil2base64(pil_image)
|
|
parts.append({"inline_data": {"mime_type": "image/png", "data": image_base64}})
|
|
|
|
# Add text
|
|
parts.append({"text": prompt})
|
|
|
|
contents.append({"parts": parts})
|
|
|
|
# Build request
|
|
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
|
generation_config = {
|
|
"maxOutputTokens": max_tokens,
|
|
"temperature": temperature,
|
|
}
|
|
|
|
# Add seed if specified (not -1)
|
|
if seed != -1:
|
|
generation_config["seed"] = seed
|
|
|
|
data = {"contents": contents, "generationConfig": generation_config}
|
|
|
|
# Add system instruction if provided
|
|
if system_instruction:
|
|
data["systemInstruction"] = system_instruction
|
|
|
|
headers = {"x-goog-api-key": api_key, "content-type": "application/json"}
|
|
|
|
# Make request
|
|
response = requests.post(url, json=data, headers=headers, timeout=timeout)
|
|
|
|
# Handle errors
|
|
if response.status_code != 200:
|
|
error_msg = f"Gemini API request failed with status {response.status_code}"
|
|
try:
|
|
error_data = response.json()
|
|
if error_data.get("error"):
|
|
error_msg = error_data["error"].get("message", error_msg)
|
|
except Exception:
|
|
pass
|
|
raise Exception(error_msg)
|
|
|
|
response_data = response.json()
|
|
|
|
if response_data.get("error"):
|
|
raise Exception(response_data.get("error").get("message", "Unknown error"))
|
|
|
|
# Extract text response and token usage from Gemini format
|
|
usage_metadata = response_data.get("usageMetadata", {})
|
|
token_usage = {
|
|
"input_tokens": usage_metadata.get("promptTokenCount", 0),
|
|
"output_tokens": usage_metadata.get("candidatesTokenCount", 0),
|
|
}
|
|
|
|
if "candidates" in response_data and len(response_data["candidates"]) > 0:
|
|
candidate = response_data["candidates"][0]
|
|
if (
|
|
"content" in candidate
|
|
and "parts" in candidate["content"]
|
|
and len(candidate["content"]["parts"]) > 0
|
|
):
|
|
return candidate["content"]["parts"][0]["text"], token_usage
|
|
|
|
raise Exception("No valid response received from Gemini API")
|
|
|
|
|
|
# ComfyUI Node Class
|
|
class SFLLMChat:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"api_key": ("STRING", {"multiline": False}),
|
|
"model": (all_models, {"default": "claude-sonnet-4-5"}), # Latest & best for agents/coding
|
|
"prompt": ("STRING", {"multiline": True}),
|
|
"max_tokens": ("INT", {"default": 4096, "min": 1, "max": 200000}),
|
|
"temperature": (
|
|
"FLOAT",
|
|
{"default": 0.7, "min": 0, "max": 2.0, "step": 0.01},
|
|
),
|
|
"seed": (
|
|
"INT",
|
|
{
|
|
"default": 1,
|
|
"min": -1,
|
|
"max": 0xFFFFFFFFFFFFFFFF,
|
|
"control_after_generate": False,
|
|
"tooltip": "Random seed for reproducible results. -1 for random seed. Note: Only works with Gemini models, Claude doesn't support seeds.",
|
|
},
|
|
),
|
|
"timeout": (
|
|
"INT",
|
|
{
|
|
"default": 500,
|
|
"min": 10,
|
|
"max": 3600,
|
|
"tooltip": "Request timeout in seconds. Increase for large text processing (default: 500s)",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
|
|
"image": ("IMAGE",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("response", "token_usage")
|
|
FUNCTION = "chat"
|
|
CATEGORY = "Stillfront/LLM"
|
|
|
|
def chat(
|
|
self,
|
|
api_key: str,
|
|
model: str,
|
|
prompt: str,
|
|
max_tokens: int,
|
|
temperature: float,
|
|
seed: int,
|
|
timeout: int,
|
|
system_prompt: str = "",
|
|
image: Optional[Tensor] = None,
|
|
):
|
|
# Check for API key in environment if not provided
|
|
if not api_key or api_key.strip() == "":
|
|
if model in gemini_models:
|
|
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get(
|
|
"GOOGLE_API_KEY"
|
|
)
|
|
else:
|
|
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get(
|
|
"CLAUDE_API_KEY"
|
|
)
|
|
|
|
if not api_key:
|
|
if model in gemini_models:
|
|
raise Exception(
|
|
"Gemini API key is required. Provide it in the node or set GEMINI_API_KEY environment variable."
|
|
)
|
|
else:
|
|
raise Exception(
|
|
"Claude API key is required. Provide it in the node or set ANTHROPIC_API_KEY environment variable."
|
|
)
|
|
|
|
# Route to appropriate API based on model
|
|
if model in gemini_models:
|
|
response, token_usage = call_gemini_api(
|
|
api_key=api_key.strip(),
|
|
model=model,
|
|
prompt=prompt,
|
|
system_prompt=system_prompt,
|
|
image=image,
|
|
max_tokens=max_tokens,
|
|
temperature=temperature,
|
|
seed=seed,
|
|
timeout=timeout,
|
|
)
|
|
else:
|
|
response, token_usage = call_claude_api(
|
|
api_key=api_key.strip(),
|
|
model=model,
|
|
prompt=prompt,
|
|
system_prompt=system_prompt,
|
|
image=image,
|
|
max_tokens=max_tokens,
|
|
temperature=temperature,
|
|
seed=seed,
|
|
timeout=timeout,
|
|
)
|
|
|
|
input_tokens = token_usage["input_tokens"]
|
|
output_tokens = token_usage["output_tokens"]
|
|
total_tokens = input_tokens + output_tokens
|
|
|
|
usage_summary = (
|
|
f"Input: {input_tokens} | Output: {output_tokens} | Total: {total_tokens}"
|
|
)
|
|
|
|
return (response, usage_summary)
|
|
|
|
|
|
# Node registration
|
|
NODE_CLASS_MAPPINGS = {
|
|
"SFLLMChat": SFLLMChat,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"SFLLMChat": "SF LLM Chat",
|
|
}
|