Files

742 lines
29 KiB
Python

"""ComfyUI node classes: one per media model, plus Chat and LoRA helpers."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import mimetypes
import os
import re
import time
import uuid
from decimal import Decimal, InvalidOperation
from typing import Any
from comfy_api.latest import io
from . import config, media
from .client import (
SpicyApiError,
SpicyClient,
SpicyTaskFailed,
output_assets,
output_text,
)
from .fields import (
OMIT,
RERUN_SEED,
RERUN_SEED_TOOLTIP,
FieldSpec,
OutputPlan,
build_fields,
category_for,
model_page,
needs_rerun_seed,
node_id_for,
plan_outputs,
widget_to_value,
)
from .pricing import badge_for, describe_price, format_usd
log = logging.getLogger("spicyapi")
LORAS = io.Custom("SPICY_LORAS")
NO_KEY_MESSAGE = (
"No SpicyAPI key is set. Open Settings > SpicyAPI and paste a key from "
"https://spicyapi.ai/console/keys, or set the SPICY_API_KEY environment variable, then run "
"the workflow again."
)
UPLOAD_CACHE_SECONDS = 12 * 3600
# Fast, inexpensive and able to read images; the first one still in the catalogue is preselected.
CHAT_DEFAULTS = ("google/gemini-3.7-flash/chat", "google/gemini-3-flash-preview/chat", "deepseek/v4.1-flash/chat")
DEFAULT_TASK_TIMEOUT_SECONDS = 3600
# sha256 of the bytes, per account -> (spicy:// URI, uploaded at). Uploaded files are kept for a
# day, so re-running a workflow with a different prompt does not send the same picture again.
_upload_cache: dict[tuple[str, str], tuple[str, float]] = {}
def _interrupt_check() -> None:
import comfy.model_management
comfy.model_management.throw_exception_if_processing_interrupted()
def _show(node_id: str | None, text: str) -> None:
if not node_id:
return
try:
from server import PromptServer
PromptServer.instance.send_progress_text(text, node_id)
except Exception: # the status line is a courtesy; never fail a run over it
pass
def _money(value: Any) -> Decimal | None:
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return None
async def _upload(client: SpicyClient, data: bytes, content_type: str) -> str:
account = hashlib.sha256(client.api_key.encode()).hexdigest()[:16]
digest = hashlib.sha256(data).hexdigest()
cached = _upload_cache.get((account, digest))
if cached and time.time() - cached[1] < UPLOAD_CACHE_SECONDS:
return cached[0]
uri = await client.upload(data, content_type)
_upload_cache[(account, digest)] = (uri, time.time())
return uri
def _content_type_for_path(path: str) -> str:
guessed, _ = mimetypes.guess_type(path)
suffix = os.path.splitext(path)[1].lower()
overrides = {
".md": "text/markdown",
".markdown": "text/markdown",
".key": "application/vnd.apple.keynote",
".pages": "application/vnd.apple.pages",
".numbers": "application/vnd.apple.numbers",
".mp3": "audio/mpeg",
".wav": "audio/wav",
}
return overrides.get(suffix) or guessed or "application/octet-stream"
def _input_file(name: str) -> str:
"""Resolve a file name inside ComfyUI's input folder, and nowhere else.
Workflows are shared freely, so a path typed into a node must not be able to reach outside
the folder meant for inputs (a workflow pointing at ~/.ssh would otherwise upload it).
"""
import folder_paths
base = os.path.realpath(folder_paths.get_input_directory())
candidate = os.path.realpath(os.path.join(base, name))
if os.path.commonpath([base, candidate]) != base:
raise ValueError(
f"{name} is outside ComfyUI's input folder. Put the file in {base} and give its name, "
"or use an https:// link."
)
if not os.path.isfile(candidate):
raise ValueError(f"{name} was not found in ComfyUI's input folder ({base}).")
return candidate
def _socket_values(value: Any) -> list[Any]:
"""Values connected to an Autogrow group, in socket order."""
if value is None:
return []
if isinstance(value, dict):
def index(key: str) -> int:
match = re.search(r"(\d+)$", key)
return int(match.group(1)) if match else 0
return [value[k] for k in sorted(value, key=index) if value[k] is not None]
return [value]
async def _field_value(client: SpicyClient, spec: FieldSpec, raw: Any) -> Any:
if spec.kind == "image":
pictures = media.split_image_batch(raw)
if not pictures:
return OMIT
if len(pictures) > 1:
log.info("SpicyAPI: '%s' takes one image; using the first of %d", spec.label, len(pictures))
return await _upload(client, *media.encode_image(pictures[0]))
if spec.kind == "images":
pictures = [p for value in _socket_values(raw) for p in media.split_image_batch(value)]
if not pictures:
return OMIT
return [await _upload(client, *media.encode_image(p)) for p in pictures]
if spec.kind == "video":
return OMIT if raw is None else await _upload(client, *media.encode_video(raw))
if spec.kind == "videos":
clips = _socket_values(raw)
return [await _upload(client, *media.encode_video(c)) for c in clips] if clips else OMIT
if spec.kind == "audio":
return OMIT if raw is None else await _upload(client, *media.encode_audio(raw))
if spec.kind == "audios":
clips = _socket_values(raw)
return [await _upload(client, *media.encode_audio(c)) for c in clips] if clips else OMIT
if spec.kind == "loras":
return list(raw) if raw else OMIT
if spec.kind == "file":
text = str(raw or "").strip()
if not text:
return OMIT
if text.startswith(("http://", "https://", "spicy://")):
return text
path = _input_file(text)
with open(path, "rb") as handle:
data = handle.read()
return await _upload(client, data, _content_type_for_path(path))
value = widget_to_value(spec, raw)
if value is not OMIT and spec.kind == "string" and spec.required and not str(value).strip():
raise ValueError(f"'{spec.label}' is required")
return value
def _input_for(spec: FieldSpec) -> io.Input:
common = {"tooltip": spec.tooltip or None}
optional = not spec.required
label = spec.label if spec.label != spec.name else None
if spec.kind in ("image", "video", "audio"):
socket = {"image": io.Image, "video": io.Video, "audio": io.Audio}[spec.kind]
return socket.Input(spec.name, display_name=label, optional=optional, **common)
if spec.kind in ("images", "videos", "audios"):
socket = {"images": io.Image, "videos": io.Video, "audios": io.Audio}[spec.kind]
# Sockets are named after the field (reference_image_0, reference_image_1...), since the
# group label itself is not drawn on the node.
stem = re.sub(r"_urls?$", "", spec.name)
prefix = f"{stem}_"
return io.Autogrow.Input(
spec.name,
template=io.Autogrow.TemplatePrefix(
socket.Input(stem, optional=spec.min_items == 0),
prefix=prefix,
min=spec.min_items,
max=spec.max_items,
),
display_name=label,
optional=optional,
**common,
)
if spec.kind == "loras":
return LORAS.Input(spec.name, display_name=label, optional=True, **common)
if spec.kind in ("int_socket", "float_socket"):
number = io.Int if spec.kind == "int_socket" else io.Float
return number.Input(spec.name, display_name=label, optional=True, force_input=True, **common)
widget = {"advanced": spec.advanced or None, "display_name": label, **common}
if spec.kind == "combo":
return io.Combo.Input(spec.name, options=spec.options, default=spec.default, **widget)
if spec.kind == "bool":
return io.Boolean.Input(spec.name, default=bool(spec.default), **widget)
if spec.kind == "int":
extra: dict[str, Any] = {}
if spec.seed:
# Fixed, not randomize: every run is billed, so queueing the same workflow twice
# should reuse the cached result instead of paying for a second one by surprise.
extra["control_after_generate"] = io.ControlAfterGenerate.fixed
if spec.slider:
extra["display_mode"] = io.NumberDisplay.slider
return io.Int.Input(
spec.name,
default=int(spec.default),
min=int(spec.minimum) if spec.minimum is not None else -(2**31),
max=int(spec.maximum) if spec.maximum is not None else 2**31 - 1,
step=int(spec.step or 1),
**extra,
**widget,
)
if spec.kind == "float":
extra = {"display_mode": io.NumberDisplay.slider} if spec.slider else {}
step = float(spec.step or 0.01)
return io.Float.Input(
spec.name,
default=float(spec.default),
min=float(spec.minimum) if spec.minimum is not None else -1e9,
max=float(spec.maximum) if spec.maximum is not None else 1e9,
step=step,
round=step,
**extra,
**widget,
)
if spec.kind in ("string", "file", "json"):
placeholder = "File name in ComfyUI/input, or an https:// link" if spec.kind == "file" else None
return io.String.Input(
spec.name,
default=str(spec.default or ""),
multiline=spec.multiline,
placeholder=placeholder,
**widget,
)
raise ValueError(f"unhandled field kind {spec.kind}")
def _outputs_for(plan: OutputPlan) -> list[io.Output]:
task_id = io.String.Output(
"task_id", display_name="task id", tooltip="The SpicyAPI task id, for the console logs or support."
)
if plan.primary == "image":
outputs: list[io.Output] = [io.Image.Output("images", display_name="images")]
if plan.alpha:
outputs.append(
io.Mask.Output("alpha", display_name="alpha", tooltip="Transparency of each image: 1 is opaque.")
)
return [*outputs, task_id]
if plan.primary == "video":
outputs = [io.Video.Output("video", display_name="video")]
if plan.last_frame:
outputs.append(
io.Image.Output("last_frame", display_name="last frame", tooltip="Needs return_last_frame turned on.")
)
return [*outputs, task_id]
if plan.primary == "audio":
return [
io.Audio.Output(
"audio",
display_name="audio",
tooltip="Several results come back as a batch; Save Audio writes one file each.",
),
task_id,
]
if plan.primary == "text":
return [io.String.Output("text", display_name="text"), task_id]
return [
io.Image.Output("previews", display_name="previews"),
io.String.Output("text", display_name="text"),
task_id,
]
async def _download_all(
client: SpicyClient, assets: list[dict[str, Any]], prefix: str, limit: int | None = None
) -> list[bytes]:
wanted = [a for a in assets if str(a.get("mime") or "").startswith(prefix) and a.get("url")]
if limit is not None:
wanted = wanted[:limit]
return list(await asyncio.gather(*(client.download(str(a["url"])) for a in wanted)))
async def _collect_outputs(client: SpicyClient, plan: OutputPlan, task: dict[str, Any]) -> list[Any]:
from comfy_execution.graph_utils import ExecutionBlocker
task_id = str(task.get("taskId") or "")
assets = output_assets(task)
if plan.primary == "image":
blobs = await _download_all(client, assets, "image/")
images, alpha = media.decode_images(blobs)
return [images, alpha, task_id] if plan.alpha else [images, task_id]
if plan.primary == "video":
videos = await _download_all(client, assets, "video/", limit=1)
if not videos:
raise ValueError(f"task {task_id} finished without a video")
result: list[Any] = [media.decode_video(videos[0])]
if plan.last_frame:
frames = await _download_all(client, assets, "image/", limit=1)
result.append(
media.decode_images(frames)[0]
if frames
else ExecutionBlocker("Turn on return_last_frame on the SpicyAPI node to get the last frame.")
)
return [*result, task_id]
if plan.primary == "audio":
clips = [media.decode_audio(blob) for blob in await _download_all(client, assets, "audio/")]
if not clips:
raise ValueError(f"task {task_id} finished without audio")
return [_audio_batch(clips), task_id]
if plan.primary == "text":
return [_task_text(task), task_id]
blobs = await _download_all(client, assets, "image/")
previews = media.decode_images(blobs)[0] if blobs else media.empty_image()
return [previews, _task_text(task), task_id]
def _task_text(task: dict[str, Any]) -> str:
text = output_text(task)
if text is not None:
return text
output = task.get("output")
if isinstance(output, dict):
rest = {k: v for k, v in output.items() if k != "assets"}
if rest:
return json.dumps(rest, ensure_ascii=False, indent=2)
return ""
def _audio_batch(clips: list[dict[str, Any]]) -> dict[str, Any]:
"""Several clips as one AUDIO batch, padded to the longest and matched on sample rate."""
import torch
rate = clips[0]["sample_rate"]
channels = max(c["waveform"].shape[1] for c in clips)
waves = []
for clip in clips:
wave = clip["waveform"][0]
if clip["sample_rate"] != rate:
length = round(wave.shape[1] * rate / clip["sample_rate"])
wave = torch.nn.functional.interpolate(wave.unsqueeze(0), size=length, mode="linear", align_corners=False)[
0
]
if wave.shape[0] < channels:
wave = wave.repeat(channels // wave.shape[0], 1)[:channels]
waves.append(wave)
length = max(w.shape[1] for w in waves)
padded = [torch.nn.functional.pad(w, (0, length - w.shape[1])) for w in waves]
return {"waveform": torch.stack(padded), "sample_rate": rate}
async def _quote_within_limit(client: SpicyClient, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Quote the run and stop, before anything is charged, if it could exceed the user's limit."""
quote = await client.quote(model_id, payload)
ceiling = config.get_max_cost_per_run()
worst = _money(quote.get("maxCharge")) or _money(quote.get("estimatedCost"))
if ceiling is not None and worst is not None and worst > ceiling:
raise ValueError(
f"This run could cost up to {format_usd(worst)}, above your limit of "
f"{format_usd(ceiling)} per run (Settings > SpicyAPI). Nothing was charged."
)
return quote
async def run_model(
model: dict[str, Any],
specs: list[FieldSpec],
plan: OutputPlan,
node_id: str | None,
values: dict[str, Any],
) -> list[Any]:
api_key, _ = config.get_api_key()
if not api_key:
raise ValueError(NO_KEY_MESSAGE)
model_id = str(model["model"])
started = time.monotonic()
async with SpicyClient(api_key, config.get_base_url(), interrupt_check=_interrupt_check) as client:
_show(node_id, "Preparing inputs...")
payload: dict[str, Any] = {}
for spec in specs:
value = await _field_value(client, spec, values.get(spec.name))
if value is not OMIT:
payload[spec.name] = value
quote = await _quote_within_limit(client, model_id, payload)
estimated = str(quote.get("estimatedCost") or "")
_show(node_id, f"Submitting. Estimated {format_usd(estimated)}")
idempotency_key = str(uuid.uuid4())
try:
task = await client.create_task(
model_id,
payload,
idempotency_key=idempotency_key,
quote_id=quote.get("quoteId"),
expected_cost=estimated or None,
)
except SpicyApiError as error:
if error.code != 40901:
raise
# The price moved between quote and submit: quote again and resend under the same
# key, so a task the server did accept is recovered rather than created twice.
quote = await _quote_within_limit(client, model_id, payload)
estimated = str(quote.get("estimatedCost") or "")
task = await client.create_task(
model_id,
payload,
idempotency_key=idempotency_key,
quote_id=quote.get("quoteId"),
expected_cost=estimated or None,
)
task_id = str(task.get("taskId") or "")
def on_poll(record: dict[str, Any], elapsed: float) -> None:
state = str(record.get("state") or "")
label = {"queued": "Queued", "running": "Generating"}.get(state, state.capitalize())
_show(node_id, f"{label} {int(elapsed)}s. Estimated {format_usd(estimated)}\nTask {task_id}")
timeout = float(model.get("taskTimeoutSeconds") or DEFAULT_TASK_TIMEOUT_SECONDS) + 300
try:
final = await client.wait_for_task(task_id, timeout_seconds=timeout, on_poll=on_poll)
except BaseException as error:
if type(error).__name__ == "InterruptProcessingException":
log.warning(
"SpicyAPI: stopped waiting for task %s. It keeps running on SpicyAPI and is "
"charged if it succeeds; its result stays available in the console logs.",
task_id,
)
_show(node_id, f"Stopped waiting. Task {task_id} keeps running on SpicyAPI.")
raise
if final.get("state") != "succeeded":
_show(node_id, f"Failed: {final.get('errorCode') or final.get('state')}\nTask {task_id}")
raise SpicyTaskFailed(final)
_show(node_id, "Downloading result...")
outputs = await _collect_outputs(client, plan, final)
cost = final.get("cost")
verb = "Charged" if final.get("settled") else "Held"
_show(
node_id,
f"Done in {int(time.monotonic() - started)}s. {verb} {format_usd(cost)}\nTask {task_id}",
)
return outputs
def _inputs_for(specs: list[FieldSpec]) -> list[Any]:
inputs = [_input_for(spec) for spec in specs]
if needs_rerun_seed(specs):
inputs.append(
io.Int.Input(
RERUN_SEED,
default=0,
min=0,
max=2**31 - 1,
# Fixed for the same reason as a real seed: queueing twice must not bill twice.
control_after_generate=io.ControlAfterGenerate.fixed,
tooltip=RERUN_SEED_TOOLTIP,
)
)
return inputs
def make_model_node(model: dict[str, Any]) -> type[io.ComfyNode]:
specs = build_fields(model.get("inputSchema") or {})
plan = plan_outputs(model)
node_id = node_id_for(str(model["model"]))
badge = badge_for(model)
description = "\n\n".join(
part
for part in (
str(model.get("summary") or "").strip(),
describe_price(badge),
f"Model: {model['model']}",
f"Docs and examples: {model_page(model)}" if model.get("familyPageSlug") else "",
)
if part
)
aliases = [str(model["model"]), "spicyapi"]
family = model.get("familyDisplayName")
if family:
aliases.append(str(family))
def define_schema(cls: type[io.ComfyNode]) -> io.Schema:
return io.Schema(
node_id=node_id,
display_name=str(model.get("displayName") or model["model"]),
category=category_for(model),
description=description,
inputs=_inputs_for(specs),
outputs=_outputs_for(plan),
hidden=[io.Hidden.unique_id],
search_aliases=aliases,
)
async def execute(cls: type[io.ComfyNode], **kwargs: Any) -> io.NodeOutput:
unique_id = cls.hidden.unique_id if cls.hidden is not None else None
if needs_rerun_seed(specs):
kwargs.pop(RERUN_SEED, None)
outputs = await run_model(model, specs, plan, unique_id, kwargs)
return io.NodeOutput(*outputs)
return type(
node_id,
(io.ComfyNode,),
{"define_schema": classmethod(define_schema), "execute": classmethod(execute)},
)
# -- LoRA ------------------------------------------------------------------
class SpicyLoRA(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="SpicyAPI_LoRA",
display_name="SpicyAPI LoRA",
category="SpicyAPI/Utils",
description=(
"Adds a LoRA weights file to a SpicyAPI LoRA model. Chain several of these to "
"stack LoRAs; each model accepts up to three."
),
inputs=[
io.String.Input(
"url",
default="",
placeholder="https://.../weights.safetensors",
tooltip="Direct download URL of a .safetensors LoRA file. It must open without a login.",
),
io.Float.Input(
"scale",
default=1.0,
min=0.0,
max=4.0,
step=0.05,
round=0.01,
tooltip="Strength of this LoRA, 0 to 4.",
),
LORAS.Input("loras", optional=True, tooltip="LoRAs from another SpicyAPI LoRA node."),
],
outputs=[LORAS.Output("loras", display_name="loras")],
search_aliases=["lora", "spicyapi lora"],
)
@classmethod
def execute(cls, url: str, scale: float, loras: list[dict[str, Any]] | None = None) -> io.NodeOutput:
stack = list(loras or [])
link = url.strip()
if link:
if not link.startswith(("http://", "https://")):
raise ValueError("The LoRA url must be an http(s) link to the weights file.")
stack.append({"path": link, "scale": round(float(scale), 4)})
return io.NodeOutput(stack)
# -- Chat ------------------------------------------------------------------
def make_chat_node(chat_models: list[dict[str, Any]]) -> type[io.ComfyNode] | None:
ids = [str(m["model"]) for m in chat_models]
if not ids:
return None
preferred = next((i for i in CHAT_DEFAULTS if i in ids), ids[0])
class SpicyChat(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="SpicyAPI_Chat",
display_name="SpicyAPI Chat",
category="SpicyAPI/Text",
description=(
"Ask a SpicyAPI text model and get its answer as a string, for example to "
"write or refine a prompt. Images are sent along when the model reads images; "
"a model that does not is refused with nothing charged."
),
inputs=[
io.Combo.Input("model", options=ids, default=preferred),
io.String.Input("prompt", multiline=True, default=""),
io.String.Input(
"system", multiline=True, default="", tooltip="Optional instructions for the model."
),
io.Autogrow.Input(
"images",
template=io.Autogrow.TemplatePrefix(
io.Image.Input("image", optional=True), prefix="image_", min=0, max=8
),
optional=True,
),
io.Int.Input(
"max_tokens",
default=2048,
min=1,
max=200000,
tooltip="Upper bound on the answer length, reasoning included.",
),
io.Float.Input(
"temperature",
default=-1.0,
min=-1.0,
max=2.0,
step=0.05,
round=0.01,
tooltip="-1 leaves it to the model.",
advanced=True,
),
io.Int.Input(
"seed",
default=0,
min=0,
max=2**31 - 1,
control_after_generate=io.ControlAfterGenerate.fixed,
tooltip="Changing it asks again; the model may still answer the same way.",
advanced=True,
),
],
outputs=[io.String.Output("text", display_name="text")],
hidden=[io.Hidden.unique_id],
search_aliases=["llm", "gpt", "prompt", "spicyapi chat"],
)
@classmethod
async def execute(
cls,
model: str,
prompt: str,
system: str,
max_tokens: int,
temperature: float,
seed: int,
images: Any = None,
) -> io.NodeOutput:
api_key, _ = config.get_api_key()
if not api_key:
raise ValueError(NO_KEY_MESSAGE)
if not prompt.strip():
raise ValueError("The prompt is empty.")
node_id = cls.hidden.unique_id if cls.hidden is not None else None
async with SpicyClient(api_key, config.get_base_url(), interrupt_check=_interrupt_check) as client:
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
pictures = [p for v in _socket_values(images) for p in media.split_image_batch(v)]
for picture in pictures:
uri = await _upload(client, *media.encode_image(picture))
content.append({"type": "image_url", "image_url": {"url": uri}})
messages: list[dict[str, Any]] = []
if system.strip():
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": content if pictures else prompt})
body: dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens}
if temperature >= 0:
body["temperature"] = temperature
_show(node_id, "Asking...")
answer = await client.chat_completion(body, idempotency_key=str(uuid.uuid4()))
choices = answer.get("choices") or []
message = (choices[0] or {}).get("message") if choices else None
text = (message or {}).get("content") if isinstance(message, dict) else None
if isinstance(text, list):
text = "".join(str(part.get("text") or "") for part in text if isinstance(part, dict))
if not isinstance(text, str):
raise ValueError("The model returned no text.")
usage = answer.get("usage") or {}
_show(node_id, f"{usage.get('total_tokens', '?')} tokens")
return io.NodeOutput(text)
return SpicyChat
def _badge_labels(specs: list[FieldSpec], badge: dict[str, Any]) -> dict[str, dict[str, str]]:
"""Dropdown label -> pricing value, for the pricing fields whose labels differ from values."""
fields = set(badge.get("fields") or [])
labels: dict[str, dict[str, str]] = {}
for spec in specs:
if spec.kind != "combo" or spec.name not in fields:
continue
mapping = {
label: (str(value).lower() if isinstance(value, bool) else str(value))
for label, value in zip(spec.options, spec.option_values, strict=True)
if value is not OMIT
}
if any(label != value for label, value in mapping.items()):
labels[spec.name] = mapping
return labels
def build_all(items: list[dict[str, Any]]) -> tuple[list[type[io.ComfyNode]], dict[str, Any]]:
"""Node classes for the catalogue, plus the price badge table keyed by node type."""
nodes: list[type[io.ComfyNode]] = [SpicyLoRA]
badges: dict[str, Any] = {}
seen: set[str] = set()
chat_models: list[dict[str, Any]] = []
for model in items:
if model.get("enabled") is False:
continue
if model.get("modality") == "text" or "chat" in (model.get("tasks") or []):
chat_models.append(model)
continue
node_id = node_id_for(str(model["model"]))
if node_id in seen:
log.warning("SpicyAPI: two models map to node type %s; keeping the first", node_id)
continue
try:
node = make_model_node(model)
node.GET_SCHEMA() # fail here, per model, rather than taking the whole extension down
except Exception as error:
log.warning("SpicyAPI: skipped %s: %s", model.get("model"), error)
continue
seen.add(node_id)
nodes.append(node)
badge = badge_for(model)
if badge:
badge["labels"] = _badge_labels(build_fields(model.get("inputSchema") or {}), badge)
badges[node_id] = badge
chat = make_chat_node(chat_models)
if chat is not None:
nodes.append(chat)
return nodes, badges