feat: 新增 ZenMux API 连接节点(厂商→模型级联选择,选项含价格标注)

- 一个节点调用 ZenMux 聚合平台全部文本类模型(20 厂商 / 138 模型)
- vendor→model 二级下拉:前端 web/zenmux_cascade.js 按厂商实时收窄模型列表
- 模型选项后缀标注输入/输出价格(USD/百万token),如 [入$0.2/M 出$1.25/M]
- 默认模型 openai/gpt-5.4-nano;具备 api_key/system/user prompt/seed/
  temperature/top_p/max_tokens/多模态图像×6/detail/base_url/proxy 等完整参数
- 模型清单随包分发(models_snapshot.json),离线可列出;
  build_snapshot.py 可随时重拉最新价格
- VALIDATE_INPUTS 宽松校验:价格快照更新后旧工作流的模型标签仍可解析执行
- base_url 默认值沿用仓库约定不含 "://",规避 ComfyUI 前端吞协议片段的老问题
- 导出 WEB_DIRECTORY 以装载前端级联脚本

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rui40000
2026-07-21 11:37:00 +08:00
co-authored by Claude Fable 5
parent a826b42576
commit ca61c48dbd
8 changed files with 2322 additions and 1 deletions
+41
View File
@@ -24,6 +24,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理
### 🤖 AI模型类
- [千问编辑图像生成 / Qwen Edit Image Generation](#4-千问编辑图像生成--qwen-edit-image-generation)
- [SDMatte 精细抠图 / SDMatte Interactive Matting](#17-sdmatte-精细抠图--sdmatte-interactive-matting)
- [ZenMux API 连接 / ZenMux API Connector](#18-zenmux-api-连接--zenmux-api-connector)
### 📝 文本处理类
- [镜头分词器 / Shot Splitter](#5-镜头分词器--shot-splitter)
@@ -710,6 +711,46 @@ ComfyUI-SDMatte 传 `aux_input="trimap"`,把模型推到了没训练过的输
---
### 18. ZenMux API 连接 / ZenMux API Connector
**分类**: `Rui-Node🐶/AI模型🤖`
**功能描述**:
连接 [ZenMux](https://zenmux.ai) 聚合平台(OpenAI 兼容协议),一个节点即可调用其收录的**所有文本类模型**(Anthropic、OpenAI、Google、DeepSeek、Qwen 等 20 家厂商、130+ 模型)。支持文本生成与多模态图像理解(最多 6 张图)。
**特色功能**:
- **厂商 → 模型二级选择**: 先在 `vendor` 下拉选厂商,`model` 下拉会自动收窄为该厂商的模型(由前端脚本 `web/zenmux_cascade.js` 联动)
- **价格直接标在选项上**: 每个模型后缀形如 `[入$0.2/M 出$1.25/M]`,即输入/输出每百万 token 的美元价格,选型时一目了然
- **离线可用的模型清单**: 模型与价格来自随包分发的 `zenmux/models_snapshot.json`;价格有变动时运行 `python zenmux/build_snapshot.py` 即可重新拉取更新
- **旧工作流兼容**: 价格快照更新后,旧工作流里保存的带旧价格标签仍能正确解析出模型 id,不会失效
**输入参数**:
- `api_key` (STRING): ZenMux 平台的 API Key(在 zenmux.ai 控制台获取)
- `vendor` (选择): 厂商筛选,默认 `openai`
- `model` (选择): 模型(带价格标注),默认 `openai/gpt-5.4-nano`
- `system_prompt` (STRING): 系统提示词
- `user_prompt` (STRING): 用户提示词
- `seed` (INT): 随机种子
- `temperature` (FLOAT, 可选): 采样温度,默认 0.7,范围 0.0 ~ 2.0
- `top_p` (FLOAT, 可选): 核采样阈值,默认 1.0
- `max_tokens` (INT, 可选): 最大输出 token 数,默认 1024
- `image_1` ~ `image_6` (IMAGE, 可选): 多模态图像输入(所选模型需支持 image 输入)
- `detail` (选择, 可选): 图像分析细节等级,auto/low/high
- `image_max_size` (INT, 可选): 发送前图像最长边缩放上限,默认 1024
- `base_url` (STRING, 可选): API 地址,默认 `zenmux.ai/api/v1`(无需写 `https://`,节点会自动补全)
- `proxy_url` (STRING, 可选): HTTP/HTTPS 代理地址,如 `127.0.0.1:7890`
**输出**:
- `text` (STRING): 模型生成的文本内容
- `model_id` (STRING): 实际调用的模型 id(如 `openai/gpt-5.4-nano`),便于下游记录
**使用场景**:
- 一个 Key 试遍多家厂商的模型,横向对比效果与成本
- 按预算选型:价格就写在下拉列表里,直接挑便宜的
- 调用 Claude / GPT / Gemini / DeepSeek 等做文本生成或图像理解
---
## 🐕 关于 Rui-Node🐶
Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。
+9 -1
View File
@@ -41,6 +41,9 @@ except Exception as _e:
print("[Ruinode] 如需使用,请安装:pip install diffusers transformers safetensors scipy opencv-python")
SDMATTE_NODE_CLASS_MAPPINGS = {}
SDMATTE_NODE_DISPLAY_NAME_MAPPINGS = {}
# 新增:ZenMux API 连接节点(聚合平台,厂商→模型二级选择,标签含价格)
from .zenmux import NODE_CLASS_MAPPINGS as ZENMUX_NODE_CLASS_MAPPINGS
from .zenmux import NODE_DISPLAY_NAME_MAPPINGS as ZENMUX_NODE_DISPLAY_NAME_MAPPINGS
# 合并节点映射字典
NODE_CLASS_MAPPINGS = {}
@@ -61,6 +64,7 @@ NODE_CLASS_MAPPINGS.update(OPENAI_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(COLORMATCHER_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(IMAGESPLITTER_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(SDMATTE_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(ZENMUX_NODE_CLASS_MAPPINGS)
# 合并节点显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -81,5 +85,9 @@ NODE_DISPLAY_NAME_MAPPINGS.update(OPENAI_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(COLORMATCHER_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGESPLITTER_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(SDMATTE_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(ZENMUX_NODE_DISPLAY_NAME_MAPPINGS)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
# 前端扩展目录(ZenMux 节点的「厂商→模型」级联脚本等)
WEB_DIRECTORY = "./web"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
+60
View File
@@ -0,0 +1,60 @@
// ZenMux 节点「厂商 → 模型」二级级联
// ==================================
// 后端把全量模型标签(含价格)都放进 model 下拉以通过校验;
// 本扩展在前端按 vendor 收窄 model 的候选列表。
// 约定:模型标签第一个空格前是 "vendor/model" 形式的 id,
// 故 label.split("/")[0] 即厂商名,与 vendor 下拉的取值同源。
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "Ruinode.ZenMuxCascade",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name !== "ZenMuxAPINode") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
const vendorW = this.widgets?.find((w) => w.name === "vendor");
const modelW = this.widgets?.find((w) => w.name === "model");
if (!vendorW || !modelW) return r;
// 全量标签快照(此时 options.values 是后端给的完整列表)
const allLabels = (modelW.options.values || []).slice();
const vendorOf = (label) => String(label).split("/")[0];
// resetIfMissing:当前值不在收窄后的列表时是否重置为列表首项。
// 手动切厂商 → 重置;加载旧工作流 → 保留(旧价格标签后端能解析)。
const applyFilter = (vendor, resetIfMissing) => {
const filtered = allLabels.filter((l) => vendorOf(l) === vendor);
modelW.options.values = filtered.length ? filtered : allLabels.slice();
if (resetIfMissing && !modelW.options.values.includes(modelW.value)) {
modelW.value = modelW.options.values[0];
modelW.callback?.(modelW.value);
}
this.setDirtyCanvas?.(true, true);
};
const origCallback = vendorW.callback;
vendorW.callback = function (value) {
const r2 = origCallback?.apply(this, arguments);
applyFilter(value, true);
return r2;
};
// 新建节点:按默认厂商先收窄一次
applyFilter(vendorW.value, false);
// 加载已保存的工作流:configure 恢复 widget 值后再按真实厂商收窄,
// 但不动用户保存的 model 选择
const onConfigure = this.onConfigure;
this.onConfigure = function () {
const r3 = onConfigure?.apply(this, arguments);
applyFilter(vendorW.value, false);
return r3;
};
return r;
};
},
});
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
"""ZenMux API 连接节点子包。"""
from .zenmux_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+91
View File
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""
从 ZenMux 拉取模型列表,提炼成节点需要的精简快照。
运行:python build_snapshot.py
产出:models_snapshot.json(随节点分发,保证离线可用)
只保留文本类模型(输出模态含 text),并预先算好基础输入/输出价格,
避免节点运行时每次都解析 ZenMux 那套分段定价结构。
"""
import json
import os
import sys
import requests
# Windows GBK 控制台打印中文/特殊字符会崩,强制 UTF-8
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
API = "https://zenmux.ai/api/v1/models"
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "models_snapshot.json")
def base_price(pricings, key):
"""取某项价格的基础段(prompt_tokens 从 0 起的那一段),单位 USD / 百万 token。"""
seg = (pricings or {}).get(key)
if not seg:
return None
vals = [s for s in seg if isinstance(s, dict) and "value" in s]
if not vals:
return None
base = None
for s in vals:
cond = s.get("conditions", {}).get("prompt_tokens", {})
if cond.get("gte", 0) in (0, None):
base = s
break
base = base or vals[0]
try:
return float(base["value"])
except (TypeError, ValueError, KeyError):
return None
def build(raw_models):
out = []
for m in raw_models:
out_mod = m.get("output_modalities", [])
if "text" not in out_mod: # 只要文本类模型
continue
pr = m.get("pricings") or {}
out.append({
"id": m["id"],
"display_name": m.get("display_name", m["id"]),
# 厂商一律取模型 id 的前缀("openai/gpt-5.4-nano" -> "openai"),
# 与前端级联 JS 的过滤规则(label.split("/")[0])保持同一真源。
"vendor": m["id"].split("/")[0] if "/" in m["id"] else m.get("owned_by", "other"),
"input_price": base_price(pr, "prompt"),
"output_price": base_price(pr, "completion"),
"context_length": m.get("context_length"),
"input_modalities": m.get("input_modalities", []),
})
# 按厂商、再按 id 排序,方便前端级联展示
out.sort(key=lambda x: (x["vendor"].lower(), x["id"].lower()))
return out
def main():
print(f"拉取 {API} ...")
r = requests.get(API, timeout=30)
r.raise_for_status()
data = r.json()["data"]
models = build(data)
payload = {
"source": API,
"count": len(models),
"models": models,
}
with open(OUT, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=1)
print(f"写入 {OUT}:{len(models)} 个文本模型")
vendors = sorted(set(m["vendor"] for m in models))
print(f"厂商 {len(vendors)} 个:{', '.join(vendors)}")
assert any(m["id"] == "openai/gpt-5.4-nano" for m in models), "默认模型缺失!"
print("默认模型 openai/gpt-5.4-nano 存在 ✓")
if __name__ == "__main__":
sys.exit(main())
+103
View File
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""
ZenMux 模型注册表
=================
从随包分发的 models_snapshot.json 加载模型清单,为节点提供:
- all_vendors() 厂商列表(模型 id 的前缀,如 "openai")
- all_model_labels() 全量「带价格」下拉标签,按厂商 → 模型排序
- default_model_label() 默认模型 openai/gpt-5.4-nano 对应的标签
- label_to_model_id() 从下拉标签解析真实 model id(对旧标签/纯 id 也兼容)
标签格式(价格单位:USD / 百万 token):
openai/gpt-5.4-nano [入$0.04/M 出$0.32/M]
标签第一个空格前恒为 model id,前端级联 JS 与后端解析都依赖这一点。
快照可用 build_snapshot.py 随时重新拉取更新。
"""
import json
import os
DEFAULT_MODEL_ID = "openai/gpt-5.4-nano"
_SNAPSHOT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"models_snapshot.json")
def _fmt_price(v):
"""10.0 -> '10',0.04 -> '0.04',None -> '?'。"""
if v is None:
return "?"
try:
s = f"{float(v):.4f}".rstrip("0").rstrip(".")
return s if s else "0"
except (TypeError, ValueError):
return "?"
def _make_label(m):
return (f"{m['id']} "
f"[入${_fmt_price(m.get('input_price'))}/M "
f"出${_fmt_price(m.get('output_price'))}/M]")
def _load_models():
"""读取快照;文件缺失/损坏时退化为只含默认模型的最小清单。"""
try:
with open(_SNAPSHOT_PATH, "r", encoding="utf-8") as f:
models = json.load(f)["models"]
if models:
return models
except Exception as e:
print(f"[Rui-Node] ZenMux 模型快照加载失败({e}),退化为最小模型清单。"
f"可运行 zenmux/build_snapshot.py 重新生成。")
return [{
"id": DEFAULT_MODEL_ID,
"vendor": DEFAULT_MODEL_ID.split("/")[0],
"input_price": None,
"output_price": None,
}]
# 模块加载时构建一次即可(ComfyUI 启动时构建节点定义)
_MODELS = _load_models()
_LABELS = [_make_label(m) for m in _MODELS]
_LABEL_TO_ID = {lb: m["id"] for lb, m in zip(_LABELS, _MODELS)}
_KNOWN_IDS = {m["id"] for m in _MODELS}
_VENDORS = sorted({m.get("vendor") or m["id"].split("/")[0] for m in _MODELS},
key=str.lower)
def all_vendors():
return list(_VENDORS)
def all_model_labels():
return list(_LABELS)
def default_model_label():
for lb, m in zip(_LABELS, _MODELS):
if m["id"] == DEFAULT_MODEL_ID:
return lb
return _LABELS[0]
def label_to_model_id(label):
"""
从下拉标签解析 model id。三层兼容:
1. 当前标签精确命中;
2. 旧版工作流里存的标签(价格已变动)→ 取第一个空格前的 id 段;
3. 用户直接填了纯 model id → 原样返回。
解析不出(空串等)返回 None,由调用方兜底到默认模型。
"""
s = (label or "").strip()
if not s:
return None
if s in _LABEL_TO_ID:
return _LABEL_TO_ID[s]
head = s.split(" ")[0].split("\t")[0]
if head in _KNOWN_IDS:
return head
# 未收录的 id(快照偏旧但模型真实存在)也放行,交给服务端判定
return head if head else None
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
# -*- coding: utf-8 -*-
"""
ZenMux API 连接节点
===================
通过 ZenMux 聚合平台(https://zenmux.ai)调用其收录的所有文本类模型。
特性:
- 模型选择做成「厂商 → 模型」两级级联:先在 vendor 下拉里选厂商,
再在 model 下拉里选该厂商的模型(联动由配套前端 zenmux_cascade.js 完成)。
- 每个模型选项后面直接标注输入/输出价格(USD / 百万 token)。
- 具备常规 API 节点的完整参数:api_key、system/user prompt、seed、
temperature、top_p、max_tokens、以及可选的多模态图像输入与代理。
- 默认模型 openai/gpt-5.4-nano。
- 随节点分发 models_snapshot.json,无网络也能列出模型;价格与列表可用
build_snapshot.py 重新拉取更新。
注:ZenMux 采用 OpenAI 兼容协议,chat 端点为
https://zenmux.ai/api/v1/chat/completions
"""
import base64
import io
import json
import os
import re
import numpy as np
import requests
from PIL import Image
from .model_registry import (
DEFAULT_MODEL_ID,
default_model_label,
all_model_labels,
all_vendors,
label_to_model_id,
)
# ZenMux 平台固定地址(OpenAI 兼容)
DEFAULT_BASE_URL = "https://zenmux.ai/api/v1"
# 输入框默认值不带 "://"——本仓库实测 ComfyUI 前端会吞掉文本框里的
# 协议片段(见 openai_node.py 的同款处理),后端 _build_chat_url 会自动补 https。
DEFAULT_BASE_URL_INPUT = "zenmux.ai/api/v1"
def _clear_proxy_env():
"""清除可能干扰 requests 的代理环境变量(仅本模块加载时执行一次)。"""
for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'):
os.environ.pop(key, None)
_clear_proxy_env()
def _build_chat_url(base_url: str) -> str:
"""由 base_url 拼出 chat/completions 端点,容忍用户填了/没填结尾斜杠。"""
s = (base_url or "").strip()
if not s:
s = DEFAULT_BASE_URL
# 补协议
if not re.match(r'^https?://', s, flags=re.IGNORECASE):
s = 'https://' + s.lstrip('/')
s = s.rstrip('/')
# 用户可能已经把 /chat/completions 填进去了
if s.lower().endswith('/chat/completions'):
return s
return s + '/chat/completions'
def _build_proxy_url(raw_proxy: str) -> str:
"""把 '127.0.0.1:7890' 或 'http://127.0.0.1:7890' 统一成带协议的地址。"""
s = (raw_proxy or "").strip()
if not s:
return ''
if re.match(r'^https?://', s, flags=re.IGNORECASE):
return s
s = re.sub(r'^https?\s*:\s*/*/?\s*', '', s, flags=re.IGNORECASE).strip('/')
return ('http://' + s) if s else ''
class ZenMuxNode:
"""ZenMux API 连接节点。"""
# ────────── 输入定义 ──────────
@classmethod
def INPUT_TYPES(cls):
vendors = all_vendors()
model_labels = all_model_labels()
default_label = default_model_label()
return {
"required": {
"api_key": ("STRING", {
"default": "",
"multiline": False,
}),
# vendor 只用于前端级联筛选;后端不依赖它(真实模型从 model 解析)。
# 默认选中 openai。
"vendor": (vendors, {
"default": "openai" if "openai" in vendors else (vendors[0] if vendors else "openai"),
}),
# model 的候选是「全量」模型标签(含价格),保证 ComfyUI 后端校验通过;
# 前端 JS 会按 vendor 把可见项收窄到该厂商。
"model": (model_labels, {
"default": default_label,
}),
"system_prompt": ("STRING", {
"default": "You are a helpful assistant.",
"multiline": True,
}),
"user_prompt": ("STRING", {
"default": "",
"multiline": True,
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
}),
},
"optional": {
"temperature": ("FLOAT", {
"default": 0.7,
"min": 0.0,
"max": 2.0,
"step": 0.1,
}),
"top_p": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.05,
}),
"max_tokens": ("INT", {
"default": 1024,
"min": 1,
"max": 200000,
}),
# 多模态图像输入(模型需支持 image 输入才有意义)
"image_1": ("IMAGE",),
"image_2": ("IMAGE",),
"image_3": ("IMAGE",),
"image_4": ("IMAGE",),
"image_5": ("IMAGE",),
"image_6": ("IMAGE",),
"detail": (["auto", "low", "high"], {
"default": "auto",
}),
"image_max_size": ("INT", {
"default": 1024,
"min": 256,
"max": 4096,
"step": 64,
}),
# 高级:一般无需改动,留空即用官方地址(无需写 https://,会自动补全)
"base_url": ("STRING", {
"default": DEFAULT_BASE_URL_INPUT,
"multiline": False,
}),
"proxy_url": ("STRING", {
"default": "",
"multiline": False,
}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text", "model_id")
FUNCTION = "generate"
CATEGORY = "Rui-Node🐶/AI模型🤖"
# ────────── 宽松校验 ──────────
@classmethod
def VALIDATE_INPUTS(cls, vendor, model):
"""
接管 vendor / model 两个下拉的校验,替代 ComfyUI 内置的
「值必须在候选列表里」检查:价格快照更新后,旧工作流里保存的
标签(带旧价格)不再逐字匹配新列表,但只要能解析出 model id
就应放行,避免整个工作流被判为无效。
"""
if label_to_model_id(model) is None:
return f"无法从 '{model}' 解析出 ZenMux 模型 id"
return True
# ────────── 图像编码 ──────────
@staticmethod
def _encode_image(img_tensor, max_size):
"""把单张图像张量 [H,W,C](0~1)编码为 base64 JPEG。"""
img_np = np.clip(img_tensor.cpu().numpy(), 0, 1)
pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB')
w, h = pil.size
if max(w, h) > max_size:
r = max_size / max(w, h)
pil = pil.resize((max(1, int(w * r)), max(1, int(h * r))), Image.LANCZOS)
buf = io.BytesIO()
pil.save(buf, format='JPEG', quality=85)
return base64.b64encode(buf.getvalue()).decode('utf-8')
# ────────── 主函数 ──────────
def generate(
self,
api_key,
vendor,
model,
system_prompt,
user_prompt,
seed,
temperature=0.7,
top_p=1.0,
max_tokens=1024,
image_1=None,
image_2=None,
image_3=None,
image_4=None,
image_5=None,
image_6=None,
detail="auto",
image_max_size=1024,
base_url=DEFAULT_BASE_URL,
proxy_url="",
):
# ---- 1. 从下拉标签解析真实 model id ----
model_id = label_to_model_id(model) or DEFAULT_MODEL_ID
chat_url = _build_chat_url(base_url)
print(f"[Rui-Node] ZenMux -> {chat_url} model={model_id}")
if not (api_key or "").strip():
return ("(错误:未填写 api_key,请在节点里填入 ZenMux 的 API Key)", model_id)
# ---- 2. 收集图像 ----
images = [
img for img in (image_1, image_2, image_3, image_4, image_5, image_6)
if img is not None
]
# ---- 3. 构造 messages ----
messages = []
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt})
if images:
parts = []
if user_prompt and user_prompt.strip():
parts.append({"type": "text", "text": user_prompt})
for img in images:
b64 = self._encode_image(img[0], image_max_size)
parts.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{b64}",
"detail": detail,
},
})
if not parts:
parts.append({"type": "text", "text": " "})
messages.append({"role": "user", "content": parts})
else:
text = (user_prompt or "").strip()
if not text:
return ("(错误:未提供图片也未提供提示词,请至少填写 user_prompt)", model_id)
messages.append({"role": "user", "content": text})
# ---- 4. 请求 ----
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key.strip()}",
}
payload = {
"model": model_id,
"messages": messages,
"seed": seed,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
}
proxies = None
p = _build_proxy_url(proxy_url)
if p:
proxies = {"http": p, "https": p}
resp = None
try:
resp = requests.post(chat_url, headers=headers, json=payload,
proxies=proxies, timeout=180)
resp.raise_for_status()
data = resp.json()
if data.get("choices"):
msg = data["choices"][0].get("message", {})
content = msg.get("content", "")
if isinstance(content, list): # 少数模型返回分段内容
content = "".join(
seg.get("text", "") for seg in content if isinstance(seg, dict)
)
return (content or "", model_id)
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}", model_id)
except requests.exceptions.ConnectionError as e:
return (f"连接失败(请检查网络/代理): {e}", model_id)
except requests.exceptions.Timeout:
return ("请求超时(180s),请检查网络或 ZenMux 服务状态。", model_id)
except requests.exceptions.HTTPError:
code = resp.status_code if resp is not None else "?"
body = resp.text[:600] if resp is not None else ""
return (f"HTTP 错误 {code}: {body}", model_id)
except Exception as e:
return (f"请求异常: {type(e).__name__}: {e}", model_id)
# ────────── ComfyUI 注册 ──────────
NODE_CLASS_MAPPINGS = {
"ZenMuxAPINode": ZenMuxNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ZenMuxAPINode": "ZenMux API 连接 / ZenMux API Connector",
}