ComfyUI's v3 validation fan-out repeated one validate_inputs() error string across every widget input and blocked execute() entirely, so users saw a wall of duplicate console errors and never saw the actual problem in the node's own status/help output in the UI. Removed validate_inputs() from GroqNode and OpenrouterNode; the same checks (API key required, manual model required, additional_params JSON shape, audio-model rejection, OpenRouter endpoint policy) now run inside execute() and return through the node's status output where the UI actually surfaces them. Updated tests and AGENTS.md to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
797 lines
34 KiB
Python
797 lines
34 KiB
Python
"""
|
|
Groq Chat Node for ComfyUI v3
|
|
Supports text and vision-language models through Groq's API.
|
|
"""
|
|
|
|
import json
|
|
import requests
|
|
import time
|
|
from PIL import Image
|
|
import torch
|
|
|
|
# Relative: ComfyUI loads this folder as a package and never puts it on
|
|
# sys.path, so an absolute `import chat_common` fails at registration time.
|
|
from . import chat_common
|
|
from comfy_api.latest import io
|
|
|
|
# ============================================================================
|
|
# MODULE-LEVEL CONSTANTS (Dynamic Model Fetching)
|
|
# ============================================================================
|
|
|
|
# Module-level cache for dynamically fetched models (5-minute TTL)
|
|
_groq_model_cache = {
|
|
"models": None,
|
|
"vision_models": None,
|
|
"last_fetch": 0,
|
|
"cache_ttl": 300, # 5 minutes
|
|
"last_failure": 0,
|
|
"failure_backoff": 60 # don't re-try a failing fetch on every execution
|
|
}
|
|
|
|
# Model categorization mapping (hybrid approach - applied to fetched models)
|
|
# Curated chat-capable models, mirroring Groq's own Production/Preview grouping.
|
|
# Audio models are excluded because this node cannot call them at all; the
|
|
# prompt-guard classifiers are excluded because a 512-token safety classifier is
|
|
# not useful as a chat model here (they remain reachable via 'Manual Input').
|
|
MODEL_CATEGORIES = {
|
|
"Featured": ["groq/compound", "openai/gpt-oss-120b"],
|
|
"Production: Chat": ["openai/gpt-oss-20b"],
|
|
"Production: Systems": ["groq/compound-mini"],
|
|
"Preview: Chat": [
|
|
"minimaxai/minimax-m2.7",
|
|
"openai/gpt-oss-safeguard-20b",
|
|
"qwen/qwen3.6-27b",
|
|
],
|
|
}
|
|
|
|
# Speech-to-text and text-to-speech models are served by /audio/transcriptions and
|
|
# /audio/speech, not /chat/completions, so they can never work in this node.
|
|
AUDIO_MODEL_PATTERNS = ["whisper", "orpheus", "playai-tts", "tts-"]
|
|
|
|
# Prefix used for the non-selectable category separators in the model dropdown.
|
|
CATEGORY_SEPARATOR_PREFIX = "---"
|
|
|
|
# Vision detection is advisory only - it shapes hints, never blocks a request.
|
|
# Order of authority: modality metadata from the live API, then the naming
|
|
# conventions below, then this seed list. Everything here may go stale without
|
|
# breaking anything, because an image is always sent and Groq itself decides.
|
|
KNOWN_VISION_MODELS = [
|
|
"qwen/qwen3.6-27b",
|
|
]
|
|
|
|
# Field names Groq has used, or may use, to describe a model's accepted inputs.
|
|
MODALITY_FIELDS = ["input_modalities", "modalities", "supported_modalities"]
|
|
|
|
VISION_PATTERNS = ["vision", "vl", "multimodal", "omni"]
|
|
|
|
# Preferred defaults, most wanted first. The first one present in the resolved
|
|
# model list wins; if none survive a Groq reshuffle, the first real model does.
|
|
# This is what keeps a retired default from breaking the node out of the box.
|
|
PREFERRED_DEFAULT_MODELS = [
|
|
"openai/gpt-oss-120b",
|
|
"openai/gpt-oss-20b",
|
|
"groq/compound",
|
|
]
|
|
|
|
# Static fallback list (used when API unavailable)
|
|
STATIC_FALLBACK_MODELS = [
|
|
"--- Featured ---",
|
|
"groq/compound",
|
|
"openai/gpt-oss-120b",
|
|
"--- Production: Chat ---",
|
|
"openai/gpt-oss-20b",
|
|
"--- Production: Systems ---",
|
|
"groq/compound-mini",
|
|
"--- Preview: Chat ---",
|
|
"minimaxai/minimax-m2.7",
|
|
"openai/gpt-oss-safeguard-20b",
|
|
"qwen/qwen3.6-27b",
|
|
"Manual Input",
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# MODULE-LEVEL FUNCTIONS (Dynamic Model Fetching)
|
|
# ============================================================================
|
|
|
|
def _is_audio_model(model_id: str, model: dict = None) -> bool:
|
|
"""
|
|
True for speech-to-text / text-to-speech models, which this node cannot call.
|
|
|
|
A model entry that positively reports text output is taken at its word, so a
|
|
future chat model whose name happens to trip a pattern is never excluded.
|
|
Naming conventions are only the fallback for entries carrying no metadata.
|
|
"""
|
|
if model:
|
|
for field in ("output_modalities", "output_modality"):
|
|
value = model.get(field)
|
|
if isinstance(value, list) and value:
|
|
return not any(str(item).lower() == "text" for item in value)
|
|
if isinstance(value, str) and value:
|
|
return "text" not in value.lower()
|
|
|
|
lowered = model_id.lower()
|
|
return any(pattern in lowered for pattern in AUDIO_MODEL_PATTERNS)
|
|
|
|
|
|
def _is_category_separator(model_id: str) -> bool:
|
|
"""True for the '--- Category ---' rows used to group the dropdown."""
|
|
return model_id.strip().startswith(CATEGORY_SEPARATOR_PREFIX)
|
|
|
|
|
|
def _get_static_fallback_models() -> tuple[list[str], list[str]]:
|
|
"""Return comprehensive static fallback list."""
|
|
return STATIC_FALLBACK_MODELS.copy(), KNOWN_VISION_MODELS.copy()
|
|
|
|
|
|
def _categorize_groq_models(api_models: list[dict]) -> list[str]:
|
|
"""
|
|
Apply hardcoded categorization to fetched models.
|
|
Models not in mapping go to 'Other' category.
|
|
"""
|
|
# Build reverse mapping: model_id -> category
|
|
model_to_category = {}
|
|
for category, model_list in MODEL_CATEGORIES.items():
|
|
for model_id in model_list:
|
|
model_to_category[model_id] = category
|
|
|
|
# Group fetched models by category
|
|
categorized = {cat: [] for cat in MODEL_CATEGORIES.keys()}
|
|
categorized["Other"] = []
|
|
|
|
for model in api_models:
|
|
model_id = model.get("id", "")
|
|
if not model_id or not model.get("active", True):
|
|
continue
|
|
|
|
# Audio models use different endpoints and cannot be called from this node
|
|
if _is_audio_model(model_id, model):
|
|
continue
|
|
|
|
# Find matching category
|
|
if model_id in model_to_category:
|
|
categorized[model_to_category[model_id]].append(model_id)
|
|
else:
|
|
categorized["Other"].append(model_id)
|
|
|
|
# Build final list with category headers
|
|
result = []
|
|
for category in MODEL_CATEGORIES.keys():
|
|
if categorized[category]:
|
|
result.append(f"--- {category} ---")
|
|
result.extend(sorted(categorized[category]))
|
|
|
|
if categorized["Other"]:
|
|
result.append("--- Other ---")
|
|
result.extend(sorted(categorized["Other"]))
|
|
|
|
result.append("Manual Input")
|
|
return result
|
|
|
|
|
|
def _model_reports_image_input(model: dict):
|
|
"""
|
|
Read image capability out of a Groq model entry's own metadata.
|
|
|
|
Groq has not committed to a modality field, so several plausible names are
|
|
checked. Returns None when the entry says nothing, which the caller treats
|
|
as "unknown" rather than "no" - that is what stops this going stale.
|
|
"""
|
|
for field in MODALITY_FIELDS:
|
|
value = model.get(field)
|
|
if isinstance(value, list) and value:
|
|
return any(str(item).lower() == "image" for item in value)
|
|
if isinstance(value, str) and value:
|
|
# e.g. "text+image->text"; only the input side counts
|
|
return "image" in value.split("->")[0].lower()
|
|
return None
|
|
|
|
|
|
def _detect_vision_models(api_models: list[dict]) -> list[str]:
|
|
"""
|
|
Collect the models that look vision-capable, most authoritative signal first:
|
|
the entry's own modality metadata, then naming conventions, then the seed list.
|
|
|
|
This only feeds hints, so a miss costs a less specific error message rather
|
|
than a blocked request.
|
|
"""
|
|
vision_models = []
|
|
|
|
for model in api_models:
|
|
model_id = model.get("id", "")
|
|
if not model_id or not model.get("active", True) or _is_audio_model(model_id, model):
|
|
continue
|
|
|
|
reported = _model_reports_image_input(model)
|
|
if reported is not None:
|
|
if reported:
|
|
vision_models.append(model_id)
|
|
continue
|
|
|
|
if (model_id in KNOWN_VISION_MODELS or
|
|
any(pattern in model_id.lower() for pattern in VISION_PATTERNS)):
|
|
vision_models.append(model_id)
|
|
|
|
return vision_models
|
|
|
|
|
|
def _pick_default_model(models: list[str]) -> str:
|
|
"""
|
|
Choose a default that exists in the given list.
|
|
|
|
Groq retires models regularly - a hardcoded default that disappears makes the
|
|
node fail on a fresh drop-in, so fall back through preferences and then to
|
|
whatever the list actually offers.
|
|
"""
|
|
for preferred in PREFERRED_DEFAULT_MODELS:
|
|
if preferred in models:
|
|
return preferred
|
|
for model_id in models:
|
|
if not _is_category_separator(model_id) and model_id != "Manual Input":
|
|
return model_id
|
|
return "Manual Input"
|
|
|
|
|
|
def _fetch_groq_models(api_key: str = None) -> tuple[list[str], list[str]]:
|
|
"""
|
|
Fetch available models from Groq API with 5-minute caching.
|
|
|
|
Args:
|
|
api_key: Optional Groq API key. If not provided, returns static fallback.
|
|
|
|
Returns:
|
|
tuple: (categorized_model_list, vision_model_list)
|
|
Returns static fallback if API call fails or no key provided.
|
|
"""
|
|
now = time.time()
|
|
|
|
with chat_common.LOCK:
|
|
# Return cached results if still fresh
|
|
if (_groq_model_cache["models"] is not None and
|
|
now - _groq_model_cache["last_fetch"] < _groq_model_cache["cache_ttl"]):
|
|
return _groq_model_cache["models"], _groq_model_cache["vision_models"]
|
|
|
|
# If no API key, return static fallback
|
|
if not api_key or not api_key.strip():
|
|
return _get_static_fallback_models()
|
|
|
|
# Back off after a failure too, so a broken key or an offline host does not
|
|
# add a request (and its timeout) to every single node execution.
|
|
if now - _groq_model_cache["last_failure"] < _groq_model_cache["failure_backoff"]:
|
|
if _groq_model_cache["models"] is not None:
|
|
return _groq_model_cache["models"], _groq_model_cache["vision_models"]
|
|
return _get_static_fallback_models()
|
|
|
|
try:
|
|
# Fetch from Groq API
|
|
response = requests.get(
|
|
"https://api.groq.com/openai/v1/models",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
timeout=5
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise Exception(f"API returned status {response.status_code}")
|
|
|
|
data = response.json().get("data", [])
|
|
|
|
# Build categorized model list
|
|
categorized_models = _categorize_groq_models(data)
|
|
|
|
# Detect vision-capable models
|
|
vision_models = _detect_vision_models(data)
|
|
|
|
# Update cache
|
|
_groq_model_cache["models"] = categorized_models
|
|
_groq_model_cache["vision_models"] = vision_models
|
|
_groq_model_cache["last_fetch"] = now
|
|
|
|
return categorized_models, vision_models
|
|
|
|
except Exception:
|
|
_groq_model_cache["last_failure"] = now
|
|
# Return previously cached results if available
|
|
if _groq_model_cache["models"] is not None:
|
|
return _groq_model_cache["models"], _groq_model_cache["vision_models"]
|
|
# Return static fallback
|
|
return _get_static_fallback_models()
|
|
|
|
|
|
# ============================================================================
|
|
# GROQ NODE CLASS
|
|
# ============================================================================
|
|
|
|
class GroqNode(io.ComfyNode):
|
|
"""
|
|
A node for interacting with Groq's API.
|
|
Supports text and vision-language models through Groq's API.
|
|
"""
|
|
|
|
# JavaScript safe integer limit (2^53 - 1)
|
|
MAX_SAFE_INTEGER = 9007199254740991
|
|
|
|
# Class-level storage for seed counters, keyed by (model, starting seed)
|
|
_last_seed = {}
|
|
|
|
# Upper bound on tracked seed counters, so the dict cannot grow without bound
|
|
MAX_TRACKED_SEEDS = 1024
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
# Resolved together so the default is always one of the offered options,
|
|
# whatever Groq's catalogue looks like on the day.
|
|
model_options = _fetch_groq_models(api_key=None)[0]
|
|
default_model = _pick_default_model(model_options)
|
|
|
|
return io.Schema(
|
|
node_id="GroqNode",
|
|
display_name="Groq Chat",
|
|
category="Groq",
|
|
description="Interact with Groq's API for ultra-fast inference. Model list dynamically fetched from Groq API (5-min cache). Supports text generation, JSON output, and vision analysis with compatible models.",
|
|
inputs=[
|
|
io.String.Input(
|
|
"api_key",
|
|
default="",
|
|
multiline=False,
|
|
tooltip="⚠️ Your Groq API key from https://console.groq.com/keys (Note: key will be visible - take care when sharing workflows)"
|
|
),
|
|
io.Combo.Input(
|
|
"model",
|
|
options=model_options,
|
|
default=default_model,
|
|
tooltip="Select a Groq model or choose 'Manual Input'. Categories: Featured, Production (stable), Preview (evaluation). Use ComfyUI Refresh to update model list from Groq API."
|
|
),
|
|
io.String.Input(
|
|
"manual_model",
|
|
default="",
|
|
multiline=False,
|
|
tooltip="Enter a custom model identifier (only used when 'Manual Input' is selected above). Leave empty if using dropdown selection."
|
|
),
|
|
io.String.Input(
|
|
"system_prompt",
|
|
default="You are a helpful AI assistant. Please provide clear, accurate, and ethical responses.",
|
|
multiline=True,
|
|
tooltip="Optional system prompt to set the AI's behavior and context. Note: Vision models may not support system prompts - toggle 'send_system' to 'no' if needed."
|
|
),
|
|
io.String.Input(
|
|
"user_prompt",
|
|
default="",
|
|
multiline=True,
|
|
tooltip="Main prompt or question for the model. For vision tasks, describe what you want to know about the image."
|
|
),
|
|
io.Combo.Input(
|
|
"send_system",
|
|
options=["yes", "no"],
|
|
default="yes",
|
|
tooltip="Toggle system prompt sending. Set to 'no' for models that reject system prompts, which is common for vision models."
|
|
),
|
|
io.Float.Input(
|
|
"temperature",
|
|
default=0.7,
|
|
min=0.0,
|
|
max=2.0,
|
|
step=0.01,
|
|
tooltip="Controls response randomness and creativity. Lower values (0.0-0.3) = more focused and deterministic. Higher values (0.7-2.0) = more creative and varied."
|
|
),
|
|
io.Float.Input(
|
|
"top_p",
|
|
default=0.7,
|
|
min=0.0,
|
|
max=1.0,
|
|
step=0.01,
|
|
tooltip="Nucleus sampling threshold. Controls diversity of word choices. Lower values (0.0-0.3) = more focused vocabulary. Higher values (0.7-1.0) = more diverse word selection."
|
|
),
|
|
io.Int.Input(
|
|
"max_completion_tokens",
|
|
default=1000,
|
|
min=1,
|
|
max=131072,
|
|
step=1,
|
|
tooltip="Maximum number of tokens to generate in the response. Note: actual limit varies by model (check model documentation). Range: 1-131,072."
|
|
),
|
|
io.Float.Input(
|
|
"frequency_penalty",
|
|
default=0.0,
|
|
min=-2.0,
|
|
max=2.0,
|
|
step=0.01,
|
|
tooltip="Penalizes tokens based on their frequency in the output. Positive values reduce repetition. Range: -2.0 to 2.0. Note: not all models support this parameter."
|
|
),
|
|
io.Float.Input(
|
|
"presence_penalty",
|
|
default=0.0,
|
|
min=-2.0,
|
|
max=2.0,
|
|
step=0.01,
|
|
tooltip="Penalizes tokens that have already appeared in the output. Positive values encourage topic diversity. Range: -2.0 to 2.0. Note: not all models support this parameter."
|
|
),
|
|
io.Combo.Input(
|
|
"response_format",
|
|
options=["text", "json_object"],
|
|
default="text",
|
|
tooltip="Response format: 'text' for natural language, 'json_object' for structured JSON output. When using JSON, instruct the model in your prompt to output JSON."
|
|
),
|
|
io.Combo.Input(
|
|
"seed_mode",
|
|
options=["fixed", "random", "increment", "decrement"],
|
|
default="random",
|
|
tooltip="Seed behavior control: 'fixed' uses the seed_value below, 'random' generates new seed each time, 'increment' increases by 1, 'decrement' decreases by 1."
|
|
),
|
|
io.Int.Input(
|
|
"seed_value",
|
|
default=0,
|
|
min=0,
|
|
max=9007199254740991,
|
|
step=1,
|
|
tooltip="Seed value for reproducibility when seed_mode is 'fixed'. Use same seed + parameters for identical outputs. Valid range: 0-9007199254740991 (JavaScript safe integer limit)."
|
|
),
|
|
io.Int.Input(
|
|
"max_retries",
|
|
default=3,
|
|
min=0,
|
|
max=5,
|
|
step=1,
|
|
tooltip="Maximum number of automatic retry attempts for recoverable errors (rate limits, temporary server issues). 0 disables retries. Range: 0-5."
|
|
),
|
|
io.Combo.Input(
|
|
"debug_mode",
|
|
options=["off", "on"],
|
|
default="off",
|
|
tooltip="Enable detailed error messages and request debugging information. Useful for troubleshooting API issues or parameter problems."
|
|
),
|
|
io.Image.Input(
|
|
"image_input",
|
|
optional=True,
|
|
tooltip="Optional image input for vision-capable models (qwen/qwen3.6-27b at time of writing). The image is always sent and Groq decides whether the model accepts it, so newly released vision models work without updating this node. Maximum size: 2048x2048 (only the first image of a batch is sent)."
|
|
),
|
|
io.Combo.Input(
|
|
"image_format",
|
|
options=["png", "jpeg"],
|
|
default="png",
|
|
tooltip="Encoding for the attached image. PNG is lossless (best for screenshots and text); JPEG produces a much smaller request for photographic content, cutting latency and token overhead."
|
|
),
|
|
io.String.Input(
|
|
"additional_params",
|
|
default="",
|
|
multiline=True,
|
|
optional=True,
|
|
tooltip="Additional Groq API parameters in JSON format. Example: {\"stop\": [\"\\n\"], \"min_p\": 0.1}. Use for advanced model-specific parameters not exposed in the UI."
|
|
)
|
|
],
|
|
outputs=[
|
|
io.String.Output(
|
|
display_name="response"
|
|
),
|
|
io.String.Output(
|
|
display_name="status"
|
|
),
|
|
io.String.Output(
|
|
display_name="help"
|
|
)
|
|
],
|
|
is_output_node=True
|
|
)
|
|
|
|
@classmethod
|
|
def fingerprint_inputs(cls, **kwargs):
|
|
"""
|
|
Equivalent of V1's IS_CHANGED.
|
|
|
|
The seed is derived inside execute(), so with unchanged widgets ComfyUI would
|
|
serve a cached response and the non-fixed seed modes would never take effect.
|
|
Returning NaN marks the node dirty whenever the seed is meant to move.
|
|
"""
|
|
if kwargs.get("seed_mode", "fixed") != "fixed":
|
|
return float("nan")
|
|
return kwargs.get("seed_value", 0)
|
|
|
|
@classmethod
|
|
def execute(
|
|
cls,
|
|
api_key: str,
|
|
model: str,
|
|
manual_model: str,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
send_system: str,
|
|
temperature: float,
|
|
top_p: float,
|
|
max_completion_tokens: int,
|
|
frequency_penalty: float,
|
|
presence_penalty: float,
|
|
response_format: str,
|
|
seed_mode: str,
|
|
seed_value: int,
|
|
max_retries: int,
|
|
debug_mode: str,
|
|
image_format: str = "png",
|
|
image_input=None,
|
|
additional_params: str = ""
|
|
) -> io.NodeOutput:
|
|
"""
|
|
Execute chat completion request to Groq API
|
|
"""
|
|
|
|
help_text = """ComfyUI-EACloudNodes - Groq Chat (v3)
|
|
Repository: https://github.com/EnragedAntelope/ComfyUI-EACloudNodes
|
|
|
|
Key Settings:
|
|
- API Key: Get from https://console.groq.com/keys
|
|
* Also used to refresh the model list from the Groq API
|
|
- Model: Pick from the dropdown or choose 'Manual Input'
|
|
* Featured: groq/compound, openai/gpt-oss-120b (default)
|
|
* Production: stable models (openai/gpt-oss-120b is the default)
|
|
* Preview: experimental models, may be deprecated without notice
|
|
* Rows shown as '--- Category ---' are labels, not selectable models
|
|
* Speech models (Whisper, Orpheus) are excluded and rejected if entered:
|
|
they use Groq's audio endpoints, not chat completions
|
|
* The prompt-guard safety classifiers are left out of the curated list too
|
|
(512-token classifiers); reach them with 'Manual Input' if you need them
|
|
- Manual Model: custom model id, used only when 'Manual Input' is selected
|
|
- System Prompt: sets AI behavior/context
|
|
- User Prompt: main input for the model (required)
|
|
- Send System: set to 'no' for vision models that reject system prompts
|
|
- Temperature: 0.0 (focused) to 2.0 (creative)
|
|
- Top-p: nucleus sampling threshold (0.0-1.0)
|
|
- Max Completion Tokens: response length limit (varies by model)
|
|
- Frequency Penalty: reduce token frequency (-2.0 to 2.0), sent only when non-zero
|
|
- Presence Penalty: encourage topic diversity (-2.0 to 2.0), sent only when non-zero
|
|
- Response Format: text or JSON object output
|
|
- Seed Mode: fixed / random / increment / decrement
|
|
- Seed Value: seed used by 'fixed' mode (0-9007199254740991)
|
|
- Max Retries: auto-retry on rate limits and 5xx errors (0-5)
|
|
- Debug Mode: include the request body in 400-error messages (image data
|
|
is summarized rather than dumped)
|
|
|
|
Optional:
|
|
- Image Input: for vision-capable models (qwen/qwen3.6-27b at time of writing)
|
|
* An attached image is ALWAYS sent. This node does not second-guess which
|
|
models accept images - Groq decides, so a vision model released after this
|
|
node shipped works immediately, with no update here
|
|
* If Groq refuses it, the error carries a hint about what did look capable
|
|
* Max size: 2048x2048 per dimension; only the first image of a batch is sent
|
|
- Image Format: PNG (lossless) or JPEG (smaller payload for photos)
|
|
- Additional Params: extra Groq parameters as a JSON object, merged into the
|
|
request body (it overrides the widgets above on key collisions)
|
|
|
|
Vision Models:
|
|
1. Connect an image to image_input
|
|
2. Select a vision-capable model
|
|
3. Set 'send_system' to 'no' (vision models often reject system prompts)
|
|
4. Describe what you want to know about the image in user_prompt
|
|
|
|
Model List:
|
|
- Ships with a static list of known chat models
|
|
- Run the node once with a valid API key, then use ComfyUI's Refresh button to
|
|
replace the dropdown with the live list from the Groq API (cached 5 minutes)
|
|
- Falls back to the static list whenever the API is unreachable
|
|
|
|
For full documentation and examples, visit:
|
|
https://github.com/EnragedAntelope/ComfyUI-EACloudNodes"""
|
|
|
|
try:
|
|
if not api_key or not api_key.strip():
|
|
return io.NodeOutput("", "Groq API key is required. Get one at https://console.groq.com/keys", help_text)
|
|
|
|
if model == "Manual Input" and (not manual_model or not manual_model.strip()):
|
|
return io.NodeOutput("", "Manual model identifier is required when 'Manual Input' is selected", help_text)
|
|
|
|
# Sanitize and validate numeric inputs
|
|
try:
|
|
temperature = max(0.0, min(2.0, float(temperature)))
|
|
top_p = max(0.0, min(1.0, float(top_p)))
|
|
max_completion_tokens = max(1, min(131072, int(max_completion_tokens)))
|
|
frequency_penalty = max(-2.0, min(2.0, float(frequency_penalty)))
|
|
presence_penalty = max(-2.0, min(2.0, float(presence_penalty)))
|
|
max_retries = max(0, min(5, int(max_retries)))
|
|
seed_value = max(0, min(cls.MAX_SAFE_INTEGER, int(seed_value)))
|
|
except (ValueError, TypeError) as e:
|
|
return io.NodeOutput("", f"Error: Invalid parameter value - {str(e)}", help_text)
|
|
|
|
# Validate user prompt (delayed until execute to handle connected inputs)
|
|
if not user_prompt or not user_prompt.strip():
|
|
return io.NodeOutput("", "User prompt is required", help_text)
|
|
|
|
# Use manual_model if "Manual Input" is selected
|
|
actual_model = manual_model.strip() if model == "Manual Input" else model
|
|
|
|
if _is_category_separator(actual_model):
|
|
return io.NodeOutput(
|
|
"",
|
|
f"Error: '{actual_model}' is a category label, not a model. Please select a model from the dropdown.",
|
|
help_text
|
|
)
|
|
|
|
if _is_audio_model(actual_model):
|
|
return io.NodeOutput(
|
|
"",
|
|
f"Error: '{actual_model}' is a speech model served by Groq's audio endpoints "
|
|
"and cannot be used for chat completions. Please select a chat model.",
|
|
help_text
|
|
)
|
|
|
|
# Handle seed based on mode.
|
|
# Counters are keyed by (model, starting seed) and capped so long-running
|
|
# sessions cannot grow this dict without bound.
|
|
node_key = (actual_model, seed_value)
|
|
with chat_common.LOCK:
|
|
seed = chat_common.derive_seed(
|
|
cls._last_seed, node_key, seed_mode, seed_value, cls.MAX_SAFE_INTEGER)
|
|
chat_common.store_seed(
|
|
cls._last_seed, node_key, seed, cls.MAX_TRACKED_SEEDS)
|
|
|
|
# Warm the module cache so a later ComfyUI Refresh can rebuild the
|
|
# dropdown from the live Groq model list.
|
|
_, vision_models = _fetch_groq_models(api_key=api_key)
|
|
if vision_models is None:
|
|
vision_models = KNOWN_VISION_MODELS
|
|
|
|
# Deliberately NOT a gate. Refusing an image on the strength of a
|
|
# capability list means every Groq model reshuffle silently blocks a
|
|
# model that actually works. The image is always sent; Groq rejects it
|
|
# if the model cannot take it, and that 400 is annotated below with
|
|
# whichever models did look capable at the time.
|
|
is_vision_model = (
|
|
actual_model in vision_models or
|
|
any(pattern in actual_model.lower() for pattern in VISION_PATTERNS)
|
|
)
|
|
|
|
# Appended to a 400 when an image was attached to a model that did not
|
|
# look vision-capable, turning Groq's generic complaint into a lead.
|
|
vision_hint = ""
|
|
if image_input is not None and not is_vision_model:
|
|
capable = [m for m in vision_models if not _is_category_separator(m)]
|
|
vision_hint = (
|
|
f"\n\nHint: an image was attached and '{actual_model}' did not look "
|
|
"vision-capable. "
|
|
+ (f"Models that currently do: {', '.join(capable)}."
|
|
if capable else
|
|
"No model in the current list advertises image input.")
|
|
)
|
|
|
|
# Initialize messages list
|
|
messages = []
|
|
|
|
# Add system prompt if provided and enabled
|
|
if system_prompt and system_prompt.strip() and send_system == "yes":
|
|
messages.append({
|
|
"role": "system",
|
|
"content": system_prompt
|
|
})
|
|
|
|
# An attached image is always forwarded. Choosing the text-only shape
|
|
# here on a capability guess would silently drop the user's image.
|
|
if image_input is not None:
|
|
try:
|
|
if isinstance(image_input, torch.Tensor):
|
|
pil_image = chat_common.tensor_to_pil(image_input)
|
|
elif isinstance(image_input, Image.Image):
|
|
pil_image = image_input
|
|
else:
|
|
return io.NodeOutput("", "Error: Unsupported image input type", help_text)
|
|
|
|
messages.append(chat_common.encode_image_message(
|
|
pil_image, user_prompt, image_format))
|
|
except Exception as img_err:
|
|
return io.NodeOutput("", f"Image Processing Error: {str(img_err)}", help_text)
|
|
else:
|
|
# Add text-only user message
|
|
messages.append({
|
|
"role": "user",
|
|
"content": user_prompt
|
|
})
|
|
|
|
# Prepare request body with only supported parameters
|
|
body = {
|
|
"model": actual_model,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"top_p": top_p,
|
|
# Groq deprecated "max_tokens" in favour of "max_completion_tokens"
|
|
"max_completion_tokens": max_completion_tokens,
|
|
"seed": seed
|
|
}
|
|
|
|
# Only add penalty parameters if non-zero (not all models support them)
|
|
if frequency_penalty != 0:
|
|
body["frequency_penalty"] = frequency_penalty
|
|
|
|
if presence_penalty != 0:
|
|
body["presence_penalty"] = presence_penalty
|
|
|
|
# Add response format if json_object is selected
|
|
if response_format == "json_object":
|
|
body["response_format"] = {"type": "json_object"}
|
|
|
|
# Parse and add additional parameters if provided
|
|
if additional_params and additional_params.strip():
|
|
try:
|
|
extra_params = json.loads(additional_params)
|
|
except json.JSONDecodeError:
|
|
return io.NodeOutput("", "Error: Invalid JSON in additional parameters. Example format: {\"stop\": [\"\\n\"]}", help_text)
|
|
if not isinstance(extra_params, dict):
|
|
return io.NodeOutput("", "Error: Additional parameters must be a JSON object. Example format: {\"stop\": [\"\\n\"]}", help_text)
|
|
body.update(extra_params)
|
|
|
|
response, transport_error = chat_common.post_with_retries(
|
|
"https://api.groq.com/openai/v1/chat/completions",
|
|
headers={
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json"
|
|
},
|
|
body=body,
|
|
max_retries=max_retries,
|
|
)
|
|
|
|
if transport_error is not None:
|
|
return io.NodeOutput("", transport_error, help_text)
|
|
|
|
# Handle 400 errors with detailed information
|
|
if response.status_code == 400:
|
|
try:
|
|
error_json = response.json()
|
|
error_message = error_json.get("error", {}).get("message", "Unknown error")
|
|
|
|
if debug_mode == "on":
|
|
return io.NodeOutput(
|
|
"",
|
|
f"Error 400: {error_message}{vision_hint}"
|
|
f"\n\nRequest body:\n{json.dumps(chat_common.redact_body(body), indent=2)}",
|
|
help_text
|
|
)
|
|
else:
|
|
return io.NodeOutput("", f"Error 400: {error_message}{vision_hint}", help_text)
|
|
except Exception:
|
|
return io.NodeOutput(
|
|
"",
|
|
"Error: Bad request - check model name and parameters (enable debug mode for details)",
|
|
help_text
|
|
)
|
|
|
|
# Handle other response codes
|
|
if response.status_code == 401:
|
|
return io.NodeOutput("", "Error: Invalid API key", help_text)
|
|
elif response.status_code == 429:
|
|
return io.NodeOutput(
|
|
"", f"Error: Rate limit exceeded even after {max_retries + 1} attempt(s)", help_text)
|
|
elif response.status_code != 200:
|
|
return io.NodeOutput(
|
|
"", f"Error: API returned status {response.status_code}", help_text)
|
|
|
|
try:
|
|
response_json = response.json()
|
|
except requests.exceptions.JSONDecodeError:
|
|
# A 200 with a malformed body is not worth retrying
|
|
return io.NodeOutput("", "Error: Invalid JSON response from Groq", help_text)
|
|
|
|
# Extract information for status
|
|
model_used = response_json.get("model", "unknown")
|
|
tokens = response_json.get("usage", {})
|
|
prompt_tokens = tokens.get("prompt_tokens", 0)
|
|
completion_tokens = tokens.get("completion_tokens", 0)
|
|
total_tokens = prompt_tokens + completion_tokens
|
|
|
|
status_msg = f"Success: Model={model_used} | Seed={seed} | Tokens: {prompt_tokens}+{completion_tokens}={total_tokens}"
|
|
|
|
if "choices" in response_json and len(response_json["choices"]) > 0:
|
|
content = response_json["choices"][0].get("message", {}).get("content", "")
|
|
return io.NodeOutput(content, status_msg, help_text)
|
|
else:
|
|
return io.NodeOutput("", "Error: No response content from model", help_text)
|
|
|
|
except Exception as e:
|
|
# ComfyUI's cancel signal must propagate, not become a chat error.
|
|
if type(e).__name__ == "InterruptProcessingException":
|
|
raise
|
|
return io.NodeOutput("", f"Unexpected Error: {str(e)}", help_text)
|
|
|
|
|
|
# Legacy v1 compatibility (for nodes that still use old API)
|
|
NODE_CLASS_MAPPINGS = {
|
|
"GroqNode": GroqNode
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"GroqNode": "Groq Chat"
|
|
}
|