Files
2026-05-07 00:06:24 +08:00

231 lines
7.4 KiB
Python

import logging
import os
from .image_encoding import encode_comfy_image_batch_to_data_urls
from .image_presets import IMAGE_PRESET_NAMES, resolve_image_settings
from .llm_request import (
LLMAPIUserError,
build_chat_messages,
create_chat_completion,
create_openai_client,
extract_completion_text,
format_llm_error,
is_openai_error,
)
from .provider_profiles import (
PROVIDER_PROFILE_NAMES,
resolve_provider_profile,
)
from .reasoning_filter import strip_reasoning_content
logger = logging.getLogger(__name__)
def _resolve_api_key(api_key, api_key_env):
env_name = str(api_key_env or "").strip()
if not env_name:
return api_key
value = os.environ.get(env_name, "")
if not value:
raise ValueError(f"Environment variable {env_name} is not set")
return value
def _image_retry_limits(max_bytes):
first = max(1, int(max_bytes))
limits = (first, max(1, int(first * 0.72)), max(1, int(first * 0.51)))
deduped = []
for limit in limits:
if limit not in deduped:
deduped.append(limit)
return tuple(deduped)
def _as_float(value, field_name):
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be a number") from exc
def _as_int(value, field_name):
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be an integer") from exc
class LLMAPIChatNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"provider_profile": (
PROVIDER_PROFILE_NAMES,
{"default": PROVIDER_PROFILE_NAMES[0]},
),
"api_baseurl": ("STRING", {"default": ""}),
"api_key": ("STRING", {"default": ""}),
"api_key_env": ("STRING", {"default": "", "advanced": True}),
"model": ("STRING", {"default": ""}),
"system_prompt": (
"STRING",
{"multiline": True, "default": "You are a helpful assistant"},
),
"prompt": ("STRING", {"multiline": True, "default": "Hello"}),
"temperature": (
"FLOAT",
{"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.01},
),
"execution_seed": ("INT", {"default": 100, "min": 0, "max": 0xFFFFFFFF}),
"filter_thinking": ("BOOLEAN", {"default": True}),
"max_images": (
"INT",
{"default": 4, "min": 1, "max": 16, "step": 1, "advanced": True},
),
"image_preset": (
IMAGE_PRESET_NAMES,
{"default": "balanced"},
),
"custom_image_max_mb": (
"FLOAT",
{
"default": 1.5,
"min": 0.1,
"max": 32.0,
"step": 0.1,
"advanced": True,
},
),
"custom_image_max_side": (
"INT",
{
"default": 1280,
"min": 1,
"max": 8_192,
"step": 1,
"advanced": True,
},
),
},
"optional": {
"images": ("IMAGE",),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("response", "error")
FUNCTION = "run_llmapi"
CATEGORY = "ComfyUI LLM API"
@classmethod
def VALIDATE_INPUTS(
cls,
provider_profile,
api_baseurl,
model,
temperature,
max_images,
image_preset,
custom_image_max_mb,
custom_image_max_side,
):
try:
resolve_provider_profile(provider_profile, api_baseurl)
resolve_image_settings(
image_preset,
custom_image_max_mb,
custom_image_max_side,
)
temperature = _as_float(temperature, "temperature")
max_images = _as_int(max_images, "max_images")
except ValueError as exc:
return str(exc)
if not str(model).strip():
return "model is required"
if not 0.0 <= temperature <= 2.0:
return "temperature must be between 0.0 and 2.0"
if not 1 <= max_images <= 16:
return "max_images must be between 1 and 16"
return True
def run_llmapi(
self,
provider_profile,
api_baseurl,
api_key,
api_key_env,
model,
system_prompt,
prompt,
temperature,
execution_seed,
filter_thinking,
max_images,
image_preset,
custom_image_max_mb,
custom_image_max_side,
images=None,
):
try:
resolved_provider = resolve_provider_profile(provider_profile, api_baseurl)
base_url = resolved_provider["base_url"]
if not base_url:
raise ValueError(f"api_baseurl is required for {provider_profile}")
has_images = images is not None
if has_images and not resolved_provider["supports_images"]:
raise ValueError(
f"Provider profile {provider_profile} does not support images"
)
image_settings = resolve_image_settings(
image_preset,
custom_image_max_mb,
custom_image_max_side,
)
resolved_api_key = _resolve_api_key(api_key, api_key_env)
def build_messages_for_image_limit(max_bytes):
image_urls = encode_comfy_image_batch_to_data_urls(
images,
max_images=max_images,
max_bytes=max_bytes,
max_side=image_settings["max_side"],
)
return build_chat_messages(
system_prompt=system_prompt,
prompt=prompt,
image_urls=image_urls,
)
client = create_openai_client(api_key=resolved_api_key, base_url=base_url)
messages = build_chat_messages(system_prompt=system_prompt, prompt=prompt)
completion = create_chat_completion(
client=client,
model=model,
messages=messages,
temperature=temperature,
has_images=has_images,
image_retry_message_factory=(
build_messages_for_image_limit if has_images else None
),
image_retry_limits=(
_image_retry_limits(image_settings["max_bytes"]) if has_images else None
),
)
response = extract_completion_text(completion)
if filter_thinking:
response = strip_reasoning_content(response)
return (response, "")
except Exception as exc:
if not isinstance(exc, (LLMAPIUserError, ValueError)) and not is_openai_error(exc):
raise
safe_error = format_llm_error(
exc,
api_key=locals().get("resolved_api_key", api_key),
)
logger.warning("%s", safe_error)
return ("", safe_error)