Files
MeteorAndyandClaude 6f2aa13575 Align with official API nodes: VIDEO/AUDIO output + real vendor formats
Rewrite each node to faithfully mirror the official comfy_api_nodes/
contract (apis/*.py + Comfy-Org/embedded-docs). The ComfyOrg proxy
forwards the body verbatim, so the official request/response models
ARE the real vendor API.

- Video nodes now return VIDEO (InputImpl.VideoFromFile), not file paths
- Audio nodes now return AUDIO ({waveform, sample_rate}), not file paths
- Request/response bodies match official pydantic models field-for-field
- Model ids, options, defaults, ranges match official + embedded docs
- Every node exposes api_key + base_url (overridable for relays/proxies)
- utils.py: add ComfyUI-native helpers — video_from_bytes,
  download_video_output, audio_from_bytes, download_audio_output,
  poll_until, validate_prompt, tensor_to_data_uri
- Rewrite nodes_minimax.py (submit -> poll -> retrieve -> download VIDEO)
- nodes_extra.py: base_url on all three, poll_until, validate_prompt
- Clean __init__.py (drop duplicate grok import/keys); 37 nodes total
- README: output-type alignment, node list, relay examples

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-15 20:07:04 +08:00

181 lines
7.3 KiB
Python

"""LLM chat/vision node (OpenAI Chat Completions compatible).
Mirrors the official ComfyUI ``OpenAIChatNode`` (comfy_api_nodes/nodes_openai.py)
as closely as the "bring your own key" design allows, while calling the vendor API
DIRECTLY (no ComfyOrg proxy).
The official node targets OpenAI's Responses API (``/v1/responses``) through the
ComfyOrg proxy. For a BYOK node we instead use the universally available
OpenAI-compatible Chat Completions endpoint (``/v1/chat/completions``) so the same
node works out of the box against OpenAI and against relays / DeepSeek / Qwen / etc.
via the ``base_url`` override. Inputs (model list, prompt, optional image context,
validation) and the STRING output match the official node; request/response handling
follows the standard Chat Completions schema that those backends implement.
"""
from .utils import http_request, run_async, tensor_to_data_uri, validate_prompt
# Exact model id list from the official OpenAIChatNode (SupportedOpenAIModel enum),
# default "gpt-5" as in the official node / docs. Kept as an editable combo so the
# same node can target relay/DeepSeek/Qwen model ids the official list does not cover.
OPENAI_CHAT_MODELS = [
"gpt-5.5-pro",
"gpt-5.5",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"o4-mini",
"o3",
"o1-pro",
"o1",
]
class BYOKey_LLM:
"""Generate text responses from an OpenAI-compatible chat model.
Drop-in equivalent of the official OpenAI ChatGPT node: same model choices,
optional image context, and a single STRING (``output_text``) output. Uses the
standard ``/chat/completions`` endpoint so it also works against OpenAI-compatible
relays by overriding ``base_url``.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"api_key": ("STRING", {
"default": "",
"tooltip": "Your API key (sent as 'Authorization: Bearer <key>').",
}),
"base_url": ("STRING", {
"default": "https://api.openai.com/v1",
"tooltip": "API base URL. Override for OpenAI-compatible relays "
"(e.g. DeepSeek, Qwen). '/chat/completions' is appended.",
}),
"model": ("STRING", {
"default": "gpt-5",
"tooltip": "The model used to generate the response. "
"Common OpenAI ids: " + ", ".join(OPENAI_CHAT_MODELS),
}),
"prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Text inputs to the model, used to generate a response.",
}),
},
"optional": {
"images": ("IMAGE", {
"tooltip": "Optional image(s) to use as context for the model. "
"To include multiple images, use the Batch Images node.",
}),
"instructions": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "System instructions for the model on how to generate "
"the response (sent as the 'system' message).",
}),
"temperature": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.01,
"tooltip": "Controls randomness in the response (0.0-2.0).",
}),
"top_p": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Controls diversity via nucleus sampling (0.0-1.0).",
}),
"max_tokens": ("INT", {
"default": 4096,
"min": 16,
"max": 16384,
"tooltip": "Upper bound for the number of tokens generated for a response.",
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 2**31 - 1,
"control_after_generate": True,
"tooltip": "Sampling seed (best-effort; not honored by all backends).",
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("output_text",)
FUNCTION = "execute"
CATEGORY = "api/byokey/text"
DESCRIPTION = "Generate text responses from an OpenAI-compatible chat model. Supports image context when images are connected."
@staticmethod
def _build_messages(prompt, instructions, images):
"""Build the Chat Completions ``messages`` array (with optional vision)."""
messages = []
if instructions and instructions.strip():
messages.append({"role": "system", "content": instructions})
if images is not None:
content = [{"type": "text", "text": prompt}]
for i in range(images.shape[0]):
data_uri = tensor_to_data_uri(images[i].unsqueeze(0), mime_type="image/png")
content.append({
"type": "image_url",
"image_url": {"url": data_uri, "detail": "auto"},
})
messages.append({"role": "user", "content": content})
else:
messages.append({"role": "user", "content": prompt})
return messages
@staticmethod
def _extract_text(resp):
"""Extract assistant text from a Chat Completions response, matching the
official node's 'return text, else a sentinel string' behaviour."""
choices = resp.get("choices") or []
if not choices:
raise RuntimeError(f"No choices returned from API. Response={resp}")
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, list):
# Some backends return content as a list of typed parts.
parts = [
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") in (None, "text", "output_text")
]
content = "".join(parts)
if content is None or content == "":
return "No text output found in response"
return str(content)
def execute(self, api_key, base_url, model, prompt,
images=None, instructions="", temperature=1.0, top_p=1.0,
max_tokens=4096, seed=0):
# Official node validates the prompt via validate_string(strip_whitespace=False).
validate_prompt(prompt, min_length=1, strip=False)
url = f"{base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
data = {
"model": model,
"messages": self._build_messages(prompt, instructions, images),
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
}
if seed:
data["seed"] = seed
resp = run_async(http_request("POST", url, headers, json_data=data))
return (self._extract_text(resp),)