Files
rui40000-RUI-Nodes/yueguang/yueguang_node.py
T
rui40000andClaude Opus 4.8 cc6a6e3400 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>
2026-07-30 18:19:39 +08:00

444 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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",
}