From cc6a6e340041faf9e44a937ac89810caddfa5d30 Mon Sep 17 00:00:00 2001 From: rui40000 Date: Thu, 30 Jul 2026 18:19:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B6=8A=E5=85=89=20?= =?UTF-8?q?API=20=E8=BF=9E=E6=8E=A5=E8=8A=82=E7=82=B9=EF=BC=88OpenAI=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=EF=BC=8C25=20=E4=B8=AA=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E4=BB=B7=E6=A0=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参照 ZenMux 节点的架构编写,参数、输出与容错行为保持一致,便于互换。 端点 https://llm.ai-nebula.com/v1/chat/completions 与 ZenMux 的三点差异: - 模型清单内置在 yueguang/model_registry.py,不做在线快照:越光没有 可枚举的模型接口,清单与价格来自官方规范文档。少一个联网环节, 也不会因拉取失败导致下拉变空 - model id 不带厂商前缀(gpt-4o 而非 openai/gpt-4o),下拉靠排序聚类, 搜索时输 gpt/claude/deepseek/kimi 过滤 - 默认 deepseek-v4-flash(全表最便宜 $0.1/$0.3,官方示例也用它), 默认值便宜可避免误触发产生意外费用 25 个模型的价格已按文档录入并逐项校验(标签解析、价格、厂商三项一致性 全部通过)。usage_stats 比 ZenMux 多一行厂商,价格换算经手算对照无误。 沿用的实战经验:base_url 默认值不带 ://(前端会吞协议片段)、 自适应参数重试(弃用 temperature / 需 max_completion_tokens 的模型)、 VALIDATE_INPUTS 宽松放行避免价格变动使旧工作流失效。 所有 19 个参数均带中文 tooltip。 验证:链路已打通(认证被接受、响应能正确解析),但文档提供的 key 余额为 0,返回 403「用户额度不足」,故未取得实际回复内容。 换有额度的 key 即可使用。 Co-Authored-By: Claude Opus 4.8 --- README.md | 48 ++++ __init__.py | 10 + yueguang/__init__.py | 5 + yueguang/model_registry.py | 136 ++++++++++++ yueguang/yueguang_node.py | 443 +++++++++++++++++++++++++++++++++++++ 5 files changed, 642 insertions(+) create mode 100644 yueguang/__init__.py create mode 100644 yueguang/model_registry.py create mode 100644 yueguang/yueguang_node.py diff --git a/README.md b/README.md index 863ecf8..9d8e733 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理 - [千问编辑图像生成 / 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) +- [越光 API 连接 / YueGuang API Connector](#26-越光-api-连接--yueguang-api-connector) - [FeyNobg 抠图 / FeyNobg Matting](#22-feynobg-抠图--feynobg-matting) - [Lucida 抠图 / Lucida Matting](#23-lucida-抠图--lucida-matting) @@ -1100,6 +1101,53 @@ Lucida 一次就能识别整张雪碧图的全部 8 个角色(各格前景占 --- +### 26. 越光 API 连接 / YueGuang API Connector + +**分类**: `Rui-Node🐶/AI模型🤖` + +**功能描述**: +通过越光(Nebula)聚合平台调用其收录的文本类模型。OpenAI 兼容协议,chat 端点 `https://llm.ai-nebula.com/v1/chat/completions`。参数、输出与容错行为与 [ZenMux 节点](#18-zenmux-api-连接--zenmux-api-connector) 保持一致,便于两者互换。 + +**模型清单(25 个,价格单位 USD / 百万 token)**: + +| 厂商 | 模型 | 输入 | 输出 | +|:-----|:-----|-----:|-----:| +| OpenAI | gpt-5.6-sol | 4.75 | 5.00 | +| | gpt-5.6-terra | 2.375 | 2.50 | +| | gpt-5.6-luna | 0.95 | 1.00 | +| | gpt-4.1 / gpt-4.1-mini | 2.00 / 0.40 | 8.00 / 1.60 | +| | gpt-4o / gpt-4o-mini | 2.50 / 0.15 | 10.00 / 0.60 | +| | o4-mini / o3-mini | 1.10 | 4.40 | +| Anthropic | claude-opus-5 | 5.00 | 5.00 | +| | claude-opus-4-7 / 4-6 | 15.00 | 75.00 | +| | claude-sonnet-5 | 2.00 | 2.00 | +| | claude-sonnet-4-6 | 3.00 | 15.00 | +| | claude-haiku-4-5-20251001 | 0.80 | 4.00 | +| | claude-fable-5 | 3.00 | 15.00 | +| DeepSeek | deepseek-v4-pro | 2.19 | 8.76 | +| | **deepseek-v4-flash**(默认)| **0.10** | **0.30** | +| | deepseek-r1-250528 | 0.55 | 2.19 | +| | deepseek-v3-250324 | 0.27 | 1.10 | +| Kimi | kimi-k3 | 2.86 | 2.86 | +| | kimi-k2.7-code / k2.6 / k2.5 / k2-thinking | 1.00 | 4.00 | + +**输入参数**: 与 ZenMux 节点相同——`api_key`、`model`(下拉带价签)、`system_prompt`、`user_prompt`、`seed`,以及可选的 `temperature`、`top_p`、`max_tokens`、`image_1`~`image_6`、`detail`、`image_max_size`、`base_url`、`proxy_url`、`usd_to_cny`。 + +**输出**: `text` / `model_id` / `usage_stats`(五行:token 消耗、输出字数、厂商、模型与单价、美元与人民币费用) + +**与 ZenMux 节点的三点差异**: + +1. **模型清单内置,不做在线快照**。越光没有可枚举的模型接口,清单与价格来自官方规范文档,直接写在 `yueguang/model_registry.py` 里——少一个联网环节,也不会因拉取失败导致下拉变空。价格变动时改那张表即可。 +2. **model id 不带厂商前缀**(是 `gpt-4o` 而非 `openai/gpt-4o`)。下拉里同厂商靠排序聚在一起,搜索时输 `gpt` / `claude` / `deepseek` / `kimi` 过滤。 +3. 默认模型是全表最便宜的 `deepseek-v4-flash`($0.10/$0.30),官方示例也用它,默认值便宜可避免误触发时产生意外费用。 + +**沿用的实战经验**: +- `base_url` 默认值**不带 `://`**——ComfyUI 前端会吞掉文本框里的协议片段(本仓库为此修过多次),协议由后端自动补全 +- **自适应参数重试**:部分模型弃用 `temperature`、或要求用 `max_completion_tokens` 取代 `max_tokens`,命中这类 400 时会剔除/改名后自动重试,正常请求零额外开销 +- `VALIDATE_INPUTS` 宽松放行:价格表更新后旧工作流里保存的标签不再逐字匹配,但只要能解析出 model id 就放行,不会让整个工作流失效 + +--- + ## 🐕 关于 Rui-Node🐶 Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。 diff --git a/__init__.py b/__init__.py index fc65775..245b7c2 100644 --- a/__init__.py +++ b/__init__.py @@ -50,6 +50,14 @@ except Exception as _e: print(f"[Ruinode] ZenMux 节点未加载:{_e}") ZENMUX_NODE_CLASS_MAPPINGS = {} ZENMUX_NODE_DISPLAY_NAME_MAPPINGS = {} +# 新增:越光 API 连接节点(OpenAI 兼容,模型清单与价格内置) +try: + from .yueguang import NODE_CLASS_MAPPINGS as YUEGUANG_NODE_CLASS_MAPPINGS + from .yueguang import NODE_DISPLAY_NAME_MAPPINGS as YUEGUANG_NODE_DISPLAY_NAME_MAPPINGS +except Exception as _e: + print(f"[Ruinode] 越光 API 节点未加载:{_e}") + YUEGUANG_NODE_CLASS_MAPPINGS = {} + YUEGUANG_NODE_DISPLAY_NAME_MAPPINGS = {} # 新增:Markdown 转图片节点(阅读器级排版渲染) try: from .markdown_image_node import NODE_CLASS_MAPPINGS as MDIMG_NODE_CLASS_MAPPINGS @@ -122,6 +130,7 @@ 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_CLASS_MAPPINGS.update(YUEGUANG_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(MDIMG_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(TEXTBOX_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(WATERMARK_NODE_CLASS_MAPPINGS) @@ -150,6 +159,7 @@ 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) +NODE_DISPLAY_NAME_MAPPINGS.update(YUEGUANG_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(MDIMG_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(TEXTBOX_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(WATERMARK_NODE_DISPLAY_NAME_MAPPINGS) diff --git a/yueguang/__init__.py b/yueguang/__init__.py new file mode 100644 index 0000000..552d659 --- /dev/null +++ b/yueguang/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +"""越光 API 连接节点子包。""" +from .yueguang_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/yueguang/model_registry.py b/yueguang/model_registry.py new file mode 100644 index 0000000..d44be7f --- /dev/null +++ b/yueguang/model_registry.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +越光模型注册表 +============== +越光的可用模型与价格由官方规范文档给定(不像 ZenMux 有可枚举的模型接口), +因此这里直接内置清单,不做在线快照 —— 少一个联网环节,也不会因为 +拉取失败而让下拉变空。价格有变动时改这张表即可。 + +对外接口与 zenmux/model_registry.py 保持一致,便于两个节点共用同一套写法: +- all_model_labels() 带价格的下拉标签,按厂商分组、组内保持官方顺序 +- default_model_label() 默认模型对应的标签 +- label_to_model_id() 从标签解析真实 model id(对旧标签/纯 id 也兼容) +- model_prices() 按 model id 查(输入单价, 输出单价),USD/百万 token +- model_label_by_id() 按 model id 生成带价签标签 + +标签格式(价格单位:USD / 百万 token): + gpt-4o [入$2.5/M 出$10/M] +标签第一个空格前恒为 model id,后端解析依赖这一点。 + +注意:越光的 model id **不带厂商前缀**(是 gpt-4o 而非 openai/gpt-4o), +这点与 ZenMux 不同。下拉里同厂商的模型是靠排序聚在一起的, +搜索时输 gpt / claude / deepseek / kimi 即可过滤。 +""" + +# (厂商, model id, 输入单价, 输出单价) 单价单位:USD / 百万 token +# 数据来源:越光 API 接入规范(2026-07-29),docs.stars-cloud.com/cn +_MODELS_RAW = [ + # ---- OpenAI ---- + ("OpenAI", "gpt-5.6-sol", 4.75, 5.00), + ("OpenAI", "gpt-5.6-terra", 2.375, 2.50), + ("OpenAI", "gpt-5.6-luna", 0.95, 1.00), + ("OpenAI", "gpt-4.1", 2.00, 8.00), + ("OpenAI", "gpt-4.1-mini", 0.40, 1.60), + ("OpenAI", "gpt-4o", 2.50, 10.00), + ("OpenAI", "gpt-4o-mini", 0.15, 0.60), + ("OpenAI", "o4-mini", 1.10, 4.40), + ("OpenAI", "o3-mini", 1.10, 4.40), + # ---- Anthropic ---- + ("Anthropic", "claude-opus-5", 5.00, 5.00), + ("Anthropic", "claude-opus-4-7", 15.00, 75.00), + ("Anthropic", "claude-opus-4-6", 15.00, 75.00), + ("Anthropic", "claude-sonnet-5", 2.00, 2.00), + ("Anthropic", "claude-sonnet-4-6", 3.00, 15.00), + ("Anthropic", "claude-haiku-4-5-20251001", 0.80, 4.00), + ("Anthropic", "claude-fable-5", 3.00, 15.00), + # ---- DeepSeek ---- + ("DeepSeek", "deepseek-v4-pro", 2.19, 8.76), + ("DeepSeek", "deepseek-v4-flash", 0.10, 0.30), + ("DeepSeek", "deepseek-r1-250528", 0.55, 2.19), + ("DeepSeek", "deepseek-v3-250324", 0.27, 1.10), + # ---- Kimi (Moonshot) ---- + ("Kimi", "kimi-k3", 2.86, 2.86), + ("Kimi", "kimi-k2.7-code", 1.00, 4.00), + ("Kimi", "kimi-k2.6", 1.00, 4.00), + ("Kimi", "kimi-k2.5", 1.00, 4.00), + ("Kimi", "kimi-k2-thinking", 1.00, 4.00), +] + +# 默认用最便宜的 deepseek-v4-flash($0.1/$0.3):官方示例也用它, +# 且默认值便宜可以避免误触发时产生意外费用。 +DEFAULT_MODEL_ID = "deepseek-v4-flash" + + +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 "?" + + +_MODELS = [{"vendor": v, "id": i, "input_price": ip, "output_price": op} + for v, i, ip, op in _MODELS_RAW] + + +def _make_label(m): + return (f"{m['id']} " + f"[入${_fmt_price(m.get('input_price'))}/M " + f"出${_fmt_price(m.get('output_price'))}/M]") + + +_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} +_ID_TO_MODEL = {m["id"]: m for m in _MODELS} + + +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 model_prices(model_id): + """按 model id 查(输入单价, 输出单价),USD/百万 token;未知模型 (None, None)。""" + m = _ID_TO_MODEL.get(model_id) + if not m: + return (None, None) + return (m.get("input_price"), m.get("output_price")) + + +def model_label_by_id(model_id): + """按 model id 生成带价签标签;未收录的 id 原样返回。""" + m = _ID_TO_MODEL.get(model_id) + return _make_label(m) if m else (model_id or "?") + + +def model_vendor(model_id): + m = _ID_TO_MODEL.get(model_id) + return m["vendor"] if m else "?" + + +def label_to_model_id(label): + """ + 从下拉标签解析 model id。三层兼容: + 1. 当前标签精确命中; + 2. 旧工作流里存的标签(价格已变动)→ 取第一个空格前的 id 段; + 3. 用户直接填了纯 model id → 原样返回(未收录也放行,交服务端判定)。 + """ + 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 + return head if head else None diff --git a/yueguang/yueguang_node.py b/yueguang/yueguang_node.py new file mode 100644 index 0000000..f60b078 --- /dev/null +++ b/yueguang/yueguang_node.py @@ -0,0 +1,443 @@ +# -*- coding: utf-8 -*- +""" +越光 API 连接节点 +================= +通过越光(Nebula)聚合平台调用其收录的文本类模型。 +接口为 OpenAI 兼容协议,chat 端点: + https://llm.ai-nebula.com/v1/chat/completions + +与 ZenMux 节点的差异: +- 越光的可用模型与价格由官方规范文档给定,没有可枚举的模型接口, + 因此模型清单内置在 model_registry.py 里,不做在线快照; +- 越光的 model id **不带厂商前缀**(gpt-4o 而非 openai/gpt-4o), + 下拉里同厂商靠排序聚在一起,搜索时输 gpt / claude / deepseek / kimi 过滤。 + +其余行为与 ZenMux 节点一致,包括那套「自适应参数重试」—— +部分模型弃用 temperature、或要求用 max_completion_tokens 取代 max_tokens, +命中这类 400 报错时会剔除/改名对应参数后自动重试,正常请求零额外开销。 +""" +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, + all_model_labels, + default_model_label, + label_to_model_id, + model_label_by_id, + model_prices, + model_vendor, +) + +# 越光平台固定地址(OpenAI 兼容) +DEFAULT_BASE_URL = "https://llm.ai-nebula.com/v1" +# 输入框默认值不带 "://"——ComfyUI 前端会吞掉文本框里的协议片段 +# (本仓库为此修过多次),协议由 _build_chat_url 自动补全。 +DEFAULT_BASE_URL_INPUT = "llm.ai-nebula.com/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("/") + 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 "" + + +# 遇到「参数不被支持 / 已弃用」的 400 时,可安全剔除的采样参数 +# (绝不触碰 model / messages 等核心字段) +_ADJUSTABLE_PARAMS = ("temperature", "top_p", "seed", "max_tokens", + "max_completion_tokens", "frequency_penalty", + "presence_penalty", "top_k") +# 判定「这条 400 是参数问题」的关键词(命中才尝试剔除重试) +_PARAM_ERR_HINTS = ("deprecat", "unsupport", "not support", "not allowed", + "invalid", "unexpected", "unknown", "must be", "cannot", + "not permitted", "removed") + + +def _diagnose_param(resp, payload): + """ + 从 400 响应里判断是哪个采样参数导致失败,返回处理指令: + ("drop", 参数名) —— 从 payload 剔除该参数后重试 + ("rename_mct", ...) —— 把 max_tokens 改名为 max_completion_tokens 后重试 + None —— 非参数类错误,不重试 + 各模型的参数规则不一(新版 claude 弃用 temperature、OpenAI reasoning 系 + 要求 max_completion_tokens 等),靠错误消息动态识别,免维护静态清单。 + """ + try: + msg = (resp.json().get("error", {}) or {}).get("message", "") or resp.text or "" + except Exception: + msg = getattr(resp, "text", "") or "" + low = msg.lower() + if "max_completion_tokens" in low and "max_tokens" in payload: + return ("rename_mct", "max_tokens") + for name in re.findall(r"""[`'"]([a-zA-Z_]+)[`'"]""", msg): + if name in payload and name in _ADJUSTABLE_PARAMS: + return ("drop", name) + if any(h in low for h in _PARAM_ERR_HINTS): + for name in _ADJUSTABLE_PARAMS: + if name in payload and name in low: + return ("drop", name) + return None + + +class YueGuangNode: + """越光 API 连接节点。""" + + @classmethod + def INPUT_TYPES(cls): + model_labels = all_model_labels() + default_label = default_model_label() + return { + "required": { + "api_key": ("STRING", { + "default": "", + "multiline": False, + "tooltip": "越光的 API Key(形如 sk-xxxxxxxx)。\n" + "⚠ 工作流会连同此值一起保存,分享 json 前记得清空。" + }), + "model": (model_labels, { + "default": default_label, + "tooltip": "模型,标签里直接带了输入/输出单价(USD/百万 token)。\n" + "越光的 model id 不带厂商前缀,下拉里同厂商是靠排序\n" + "聚在一起的 —— 在下拉的搜索框输 gpt / claude /\n" + "deepseek / kimi 即可快速过滤。\n" + "默认 deepseek-v4-flash 是表里最便宜的($0.1/$0.3)。" + }), + "system_prompt": ("STRING", { + "default": "You are a helpful assistant.", + "multiline": True, + "tooltip": "系统提示词:设定模型的角色与总体行为准则。\n" + "输出格式要求(如「只返回 JSON」)写在这里最稳定。" + }), + "user_prompt": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "用户提示词:这一次具体要模型做什么。" + }), + "seed": ("INT", { + "default": 0, + "min": 0, + "max": 0xffffffffffffffff, + "tooltip": "随机种子。多数模型并不真正支持复现,\n" + "这里主要用于强制节点重新执行(改了它就不会走缓存)。" + }), + }, + "optional": { + "temperature": ("FLOAT", { + "default": 0.7, + "min": 0.0, + "max": 2.0, + "step": 0.1, + "tooltip": "采样温度:越低越稳定保守,越高越发散。\n" + "结构化输出用 0~0.3,创意文案用 0.7~1.0。\n" + "部分新模型已弃用该参数,节点会自动剔除后重试。" + }), + "top_p": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": "核采样:只在累计概率前 top_p 的词里挑。\n" + "与温度作用重叠,一般固定 1.0 只调温度,别两个一起动。" + }), + "max_tokens": ("INT", { + "default": 1024, + "min": 1, + "max": 200000, + "tooltip": "回复的最大长度上限。设小了会把回答从中间截断。\n" + "注意它同时是费用上限的重要因素。" + }), + "image_1": ("IMAGE", { + "tooltip": "要一并发给模型的图像 1(需所选模型支持视觉)。\n" + "会按下方最大边长压缩后转 base64 提交。" + }), + "image_2": ("IMAGE", {"tooltip": "图像 2。"}), + "image_3": ("IMAGE", {"tooltip": "图像 3。"}), + "image_4": ("IMAGE", {"tooltip": "图像 4。"}), + "image_5": ("IMAGE", {"tooltip": "图像 5。"}), + "image_6": ("IMAGE", {"tooltip": "图像 6。图越多越贵、越慢。"}), + "detail": (["auto", "low", "high"], { + "default": "auto", + "tooltip": "图像细节级别:\n" + "low 便宜快速,只看大致内容;\n" + "high 切块细看,认小字/细节更准但更贵;\n" + "auto 由服务端决定。" + }), + "image_max_size": ("INT", { + "default": 1024, + "min": 256, + "max": 4096, + "step": 64, + "tooltip": "上传前把图缩放到的最大边长。\n" + "调小可显著省钱提速,但小字与细节会看不清。" + }), + "base_url": ("STRING", { + "default": DEFAULT_BASE_URL_INPUT, + "multiline": False, + "tooltip": "接口地址,一般不用改。\n" + "**不要写 https://** —— ComfyUI 前端会吞掉 \"://\",\n" + "协议由后端自动补全,这里只填域名和路径。" + }), + "proxy_url": ("STRING", { + "default": "", + "multiline": False, + "tooltip": "HTTP 代理,同样不要带协议前缀,只填 IP:端口,\n" + "例如 127.0.0.1:7890。留空表示直连。" + }), + "usd_to_cny": ("FLOAT", { + "default": 7.2, + "min": 0.1, + "max": 100.0, + "step": 0.01, + "tooltip": "美元兑人民币汇率,仅用于把 usage_stats 输出里的\n" + "费用换算成人民币显示,不影响实际计费。\n" + "可按当日牌价自行调整。" + }), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("text", "model_id", "usage_stats") + FUNCTION = "generate" + CATEGORY = "Rui-Node🐶/AI模型🤖" + + @classmethod + def VALIDATE_INPUTS(cls, model): + """ + 接管 model 下拉的校验:价格表更新后,旧工作流里保存的标签 + (带旧价格)不再逐字匹配新列表,但只要能解析出 model id 就应放行, + 避免整个工作流被判为无效。 + """ + if label_to_model_id(model) is None: + return f"无法从 '{model}' 解析出越光模型 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) + if img_np.shape[-1] == 4: # 带 alpha 的输入丢掉 alpha + img_np = img_np[..., :3] + 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") + + @staticmethod + def _fmt_cost(v): + """费用格式化:最多 6 位小数并去尾零;未知为 '?'。""" + if v is None: + return "?" + s = f"{v:.6f}".rstrip("0").rstrip(".") + return s if s else "0" + + @staticmethod + def _build_usage_stats(model_id, usage, usd_to_cny, out_text=None): + """ + 由 API 响应的 usage、输出文本与内置单价生成五行消耗统计: + token消耗,输入:XXX,输出:XXX + 输出文字数量:XXX + 厂商:OpenAI + 模型类型:gpt-4o [入$2.5/M 出$10/M] + 价格换算,美元:XXX,人民币:XXX + usage 缺失/单价未知的项以 '?' 呈现;请求未发生时传 usage=None 记为 0 消耗。 + """ + if not isinstance(usage, dict): + usage = {"prompt_tokens": 0, "completion_tokens": 0} + in_tok = usage.get("prompt_tokens") + out_tok = usage.get("completion_tokens") + in_price, out_price = model_prices(model_id) + + usd = None + if (isinstance(in_tok, (int, float)) and isinstance(out_tok, (int, float)) + and in_price is not None and out_price is not None): + usd = in_tok / 1e6 * in_price + out_tok / 1e6 * out_price + cny = usd * usd_to_cny if usd is not None else None + + n_chars = len(out_text) if isinstance(out_text, str) else 0 + tok = lambda t: str(int(t)) if isinstance(t, (int, float)) else "?" # noqa: E731 + return (f"token消耗,输入:{tok(in_tok)},输出:{tok(out_tok)}\n" + f"输出文字数量:{n_chars}\n" + f"厂商:{model_vendor(model_id)}\n" + f"模型类型:{model_label_by_id(model_id)}\n" + f"价格换算,美元:{YueGuangNode._fmt_cost(usd)}," + f"人民币:{YueGuangNode._fmt_cost(cny)}") + + def generate( + self, + api_key, + 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_INPUT, + proxy_url="", + usd_to_cny=7.2, + ): + model_id = label_to_model_id(model) or DEFAULT_MODEL_ID + chat_url = _build_chat_url(base_url) + print(f"[Rui-Node] 越光 -> {chat_url} model={model_id}") + + zero_stats = self._build_usage_stats(model_id, None, usd_to_cny) + + if not (api_key or "").strip(): + return ("(错误:未填写 api_key,请在节点里填入越光的 API Key)", + model_id, zero_stats) + + images = [img for img in (image_1, image_2, image_3, image_4, + image_5, image_6) if img is not None] + + 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, zero_stats) + messages.append({"role": "user", "content": text}) + + 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 + dropped = [] + try: + # 自适应参数重试:部分模型弃用/不支持某些采样参数, + # 命中即剔除或改名后重试;正常请求不受影响、零额外开销。 + for _ in range(len(_ADJUSTABLE_PARAMS) + 2): + resp = requests.post(chat_url, headers=headers, json=payload, + proxies=proxies, timeout=180) + if resp.status_code == 400: + fix = _diagnose_param(resp, payload) + if fix: + action, param = fix + if action == "rename_mct": + payload["max_completion_tokens"] = payload.pop("max_tokens") + dropped.append("max_tokens→max_completion_tokens") + print("[Rui-Node] 越光: 该模型要求 max_completion_tokens," + "已改名重试") + else: + payload.pop(param, None) + dropped.append(param) + print(f"[Rui-Node] 越光: 该模型不支持参数 '{param}'," + "已剔除后重试") + continue + break + 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) + ) + stats = self._build_usage_stats(model_id, data.get("usage"), + usd_to_cny, content or "") + return (content or "", model_id, stats) + stats = self._build_usage_stats(model_id, data.get("usage"), usd_to_cny) + return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}", + model_id, stats) + except requests.exceptions.ConnectionError as e: + return (f"连接失败(请检查网络/代理): {e}", model_id, zero_stats) + except requests.exceptions.Timeout: + return ("请求超时(180s),请检查网络或越光服务状态。", + model_id, zero_stats) + except requests.exceptions.HTTPError: + code = resp.status_code if resp is not None else "?" + body = resp.text[:600] if resp is not None else "" + hint = (f"(已尝试剔除参数 {', '.join(dropped)} 仍失败)" + if (code == 400 and dropped) else "") + return (f"HTTP 错误 {code}: {body}{hint}", model_id, zero_stats) + except Exception as e: + return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats) + + +NODE_CLASS_MAPPINGS = { + "YueGuangAPINode": YueGuangNode, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "YueGuangAPINode": "越光 API 连接 / YueGuang API Connector", +}