feat: ZenMux 节点新增 usage_stats 输出,统计单次运行 token 消耗与费用

- 第三输出 usage_stats(STRING),三行格式:
  token消耗,输入:XXX,输出:XXX
  模型类型:openai/gpt-5.4-nano [入$0.2/M 出$1.25/M]
  价格换算,美元:XXX,人民币:XXX
- 费用 = 响应 usage 的 token 数 × 快照单价(USD/百万token)
- 新增可选参数 usd_to_cny(默认 7.2)供人民币换算,可按当日牌价调整
- 请求失败记 0 消耗;usage 缺失或单价未知的项显示 "?"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rui40000
2026-07-24 10:23:07 +08:00
co-authored by Claude Fable 5
parent cc5852dc18
commit 0a87ba7813
3 changed files with 91 additions and 10 deletions
+9
View File
@@ -723,6 +723,13 @@ ComfyUI-SDMatte 传 `aux_input="trimap"`,把模型推到了没训练过的输
- **快速筛选**: 模型列表按「厂商/模型名」排序聚类,同厂商模型天然相邻;在下拉的搜索框输入厂商前缀(如 `qwen/`、`anthropic/`)即可只看该厂商的模型
- **离线可用的模型清单**: 模型与价格来自随包分发的 `zenmux/models_snapshot.json`;价格有变动时运行 `python zenmux/build_snapshot.py` 即可重新拉取更新
- **旧工作流兼容**: 价格快照更新后,旧工作流里保存的带旧价格标签仍能正确解析出模型 id,不会失效
- **单次消耗统计**: `usage_stats` 输出本次运行的 token 用量与费用换算(按快照单价计算,汇率可调),格式:
```
token消耗,输入:1234,输出:567
模型类型:openai/gpt-5.4-nano [入$0.2/M 出$1.25/M]
价格换算,美元:0.000955,人民币:0.006876
```
**输入参数**:
- `api_key` (STRING): ZenMux 平台的 API Key(在 zenmux.ai 控制台获取)
@@ -738,10 +745,12 @@ ComfyUI-SDMatte 传 `aux_input="trimap"`,把模型推到了没训练过的输
- `image_max_size` (INT, 可选): 发送前图像最长边缩放上限,默认 1024
- `base_url` (STRING, 可选): API 地址,默认 `zenmux.ai/api/v1`(无需写 `https://`,节点会自动补全)
- `proxy_url` (STRING, 可选): HTTP/HTTPS 代理地址,如 `127.0.0.1:7890`
- `usd_to_cny` (FLOAT, 可选): 美元兑人民币汇率,默认 7.2,用于 `usage_stats` 的人民币换算,可按当日牌价调整
**输出**:
- `text` (STRING): 模型生成的文本内容
- `model_id` (STRING): 实际调用的模型 id(如 `openai/gpt-5.4-nano`),便于下游记录
- `usage_stats` (STRING): 单次运行的 token 消耗与费用统计(三行文本,格式见上);请求失败时记为 0 消耗,token 数缺失或单价未知的项显示 `?`
**使用场景**:
- 一个 Key 试遍多家厂商的模型,横向对比效果与成本
+17
View File
@@ -7,6 +7,8 @@ ZenMux 模型注册表
- all_model_labels() 全量「带价格」下拉标签,按厂商 → 模型排序聚类
- default_model_label() 默认模型 openai/gpt-5.4-nano 对应的标签
- label_to_model_id() 从下拉标签解析真实 model id(对旧标签/纯 id 也兼容)
- model_prices() 按 model id 查(输入单价, 输出单价),USD/百万 token
- model_label_by_id() 按 model id 生成当前快照的带价签标签
标签格式(价格单位:USD / 百万 token):
openai/gpt-5.4-nano [入$0.04/M 出$0.32/M]
@@ -63,6 +65,7 @@ _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}
_ID_TO_MODEL = {m["id"]: m for m in _MODELS}
def all_model_labels():
@@ -76,6 +79,20 @@ def default_model_label():
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 label_to_model_id(label):
"""
从下拉标签解析 model id。三层兼容:
+65 -10
View File
@@ -8,6 +8,8 @@ ZenMux API 连接节点
- 模型下拉覆盖 ZenMux 全部文本模型,按「厂商/模型名」排序聚类;
ComfyUI 下拉自带搜索,输入厂商前缀(如 "anthropic/")即可快速过滤。
- 每个模型选项后面直接标注输入/输出价格(USD / 百万 token)。
- usage_stats 输出单次运行的 token 消耗与费用(按快照单价折算,
汇率可用 usd_to_cny 参数调整)。
- 具备常规 API 节点的完整参数:api_key、system/user prompt、seed、
temperature、top_p、max_tokens、以及可选的多模态图像输入与代理。
- 默认模型 openai/gpt-5.4-nano。
@@ -32,6 +34,8 @@ from .model_registry import (
default_model_label,
all_model_labels,
label_to_model_id,
model_label_by_id,
model_prices,
)
# ZenMux 平台固定地址(OpenAI 兼容)
@@ -152,11 +156,18 @@ class ZenMuxNode:
"default": "",
"multiline": False,
}),
# usage_stats 里人民币换算用的汇率,可按当日牌价自行调整
"usd_to_cny": ("FLOAT", {
"default": 7.2,
"min": 0.1,
"max": 100.0,
"step": 0.01,
}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text", "model_id")
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("text", "model_id", "usage_stats")
FUNCTION = "generate"
CATEGORY = "Rui-Node🐶/AI模型🤖"
@@ -187,6 +198,41 @@ class ZenMuxNode:
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):
"""
由 API 响应的 usage 与快照单价生成三行消耗统计文本:
token消耗,输入:XXX,输出:XXX
模型类型:openai/gpt-5.4-nano [入$0.2/M 出$1.25/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
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"模型类型:{model_label_by_id(model_id)}\n"
f"价格换算,美元:{ZenMuxNode._fmt_cost(usd)},人民币:{ZenMuxNode._fmt_cost(cny)}")
# ────────── 主函数 ──────────
def generate(
self,
@@ -208,14 +254,19 @@ class ZenMuxNode:
image_max_size=1024,
base_url=DEFAULT_BASE_URL,
proxy_url="",
usd_to_cny=7.2,
):
# ---- 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}")
# 请求未成功前的兜底统计(0 消耗)
zero_stats = self._build_usage_stats(model_id, None, usd_to_cny)
if not (api_key or "").strip():
return ("(错误:未填写 api_key,请在节点里填入 ZenMux 的 API Key)", model_id)
return ("(错误:未填写 api_key,请在节点里填入 ZenMux 的 API Key)",
model_id, zero_stats)
# ---- 2. 收集图像 ----
images = [
@@ -247,7 +298,8 @@ class ZenMuxNode:
else:
text = (user_prompt or "").strip()
if not text:
return ("(错误:未提供图片也未提供提示词,请至少填写 user_prompt)", model_id)
return ("(错误:未提供图片也未提供提示词,请至少填写 user_prompt)",
model_id, zero_stats)
messages.append({"role": "user", "content": text})
# ---- 4. 请求 ----
@@ -275,6 +327,8 @@ class ZenMuxNode:
proxies=proxies, timeout=180)
resp.raise_for_status()
data = resp.json()
# 消耗统计(成功与格式异常场景都尽量取真实 usage)
stats = self._build_usage_stats(model_id, data.get("usage"), usd_to_cny)
if data.get("choices"):
msg = data["choices"][0].get("message", {})
content = msg.get("content", "")
@@ -282,18 +336,19 @@ class ZenMuxNode:
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)
return (content or "", model_id, stats)
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}",
model_id, stats)
except requests.exceptions.ConnectionError as e:
return (f"连接失败(请检查网络/代理): {e}", model_id)
return (f"连接失败(请检查网络/代理): {e}", model_id, zero_stats)
except requests.exceptions.Timeout:
return ("请求超时(180s),请检查网络或 ZenMux 服务状态。", model_id)
return ("请求超时(180s),请检查网络或 ZenMux 服务状态。", 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 ""
return (f"HTTP 错误 {code}: {body}", model_id)
return (f"HTTP 错误 {code}: {body}", model_id, zero_stats)
except Exception as e:
return (f"请求异常: {type(e).__name__}: {e}", model_id)
return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats)
# ────────── ComfyUI 注册 ──────────