feat: 新增越光 API 连接节点(OpenAI 兼容,25 个模型内置价格)
参照 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2a6fd42c57
commit
cc6a6e3400
@@ -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 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。
|
||||
|
||||
+10
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user