From 205a7c0e315cdaf1e0aaffecf5ca13c74240197f Mon Sep 17 00:00:00 2001 From: rui40000 Date: Wed, 19 Aug 2026 09:47:47 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B6=8A=E5=85=89=20/=20ZenMux=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=96=B0=E5=A2=9E=E7=BD=91=E7=BB=9C=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=EF=BC=8C=E5=BA=94=E5=AF=B9=20SSL=20=E6=96=AD?= =?UTF-8?q?=E8=BF=9E=E7=AD=89=E9=93=BE=E8=B7=AF=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上跑批时最常见的失败不是参数错,而是链路抖动,典型报错是 SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol'))。 此前这两个节点只有针对 HTTP 400 的「自适应参数重试」(业务层), 网络异常直接落到 except ConnectionError 返回错误字符串——而 SSLError 正是 ConnectionError 的子类,等于一次失败就放弃。 新增 max_retries(默认 3)与 timeout(默认 180)两个参数: 会重连:SSL 握手被打断、连接被重置、连接/读超时、响应体截断 (ChunkedEncodingError / JSONDecodeError),以及 429 限流与 5xx 临时故障。 不重连:参数类 400、鉴权类 401/403、404——重试结果相同,只会拖慢报错。 退避 1s→2s→4s→8s→16s 指数增长并叠加 ±25% 抖动。抖动是必要的:一条 工作流里常有多个 API 节点同时失败,无抖动会让它们在同一毫秒一起重连, 把刚缓过来的服务端再打垮一次。服务端给了 Retry-After 时以它为准。 与既有的自适应参数重试分属两层、互不消耗额度;参数被剔除后的 payload 在后续重连中保留,不会重蹈覆辙。HTTPError 报错信息补上已重连次数, 以免用户误以为没有重试。 mock 验证 24 项全通过:失败序列后成功、重试耗尽、max_retries=0/5、 退避递增与抖动、Retry-After、429/5xx/401/404 分流、读超时、响应体截断、 连接重置、参数自适应不受影响、旧工作流不传新参数的向后兼容、ZenMux 同样生效。 Co-Authored-By: Claude Opus 5 --- README.md | 66 +++++++++++++ yueguang/yueguang_node.py | 200 ++++++++++++++++++++++++++++---------- zenmux/zenmux_node.py | 199 +++++++++++++++++++++++++++---------- 3 files changed, 364 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index f7f0108..858df96 100644 --- a/README.md +++ b/README.md @@ -769,6 +769,39 @@ ComfyUI-SDMatte 传 `aux_input="trimap"`,把模型推到了没训练过的输 - 按预算选型:价格就写在下拉列表里,直接挑便宜的 - 调用 Claude / GPT / Gemini / DeepSeek 等做文本生成或图像理解 + +**网络自动重连**: +线上跑批时最常见的失败不是参数错,而是链路抖动 —— 典型报错是 +`SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol'))`, +即 TLS 握手/传输被中途打断。这类失败重连一次往往就好了,因此节点内置了重连机制。 + +| 参数 | 默认 | 说明 | +|---|---|---| +| `max_retries` | 3 | 网络失败后的自动重连次数,0 = 不重连 | +| `timeout` | 180 | 单次请求超时秒数,超时计入重连次数 | + +**会重连**:SSL 握手被打断(`SSLEOFError`)、连接被重置、连接/读超时、 +响应体传到一半截断(`ChunkedEncodingError` / JSON 解析失败), +以及 `429` 限流与 `5xx` 服务端临时故障。 + +**不重连**:参数类 `400`、鉴权类 `401/403`、`404` —— 这些重试多少次都是同样结果, +重连只会拖慢报错。 + +**退避策略**:`1s → 2s → 4s → 8s → 16s` 指数增长,每次叠加 ±25% 随机抖动。 +抖动不是可有可无的装饰 —— 一条工作流里常有多个 API 节点同时失败,没有抖动它们会在 +同一毫秒一起重连,把刚缓过来的服务端再打垮一次。服务端返回 `Retry-After` 头时以它为准。 + +**与「自适应参数重试」的关系**:两者是不同层次,互不消耗额度。参数自适应处理的是 +「这个模型不认这个参数」(HTTP 400),网络重连处理的是「没拿到完整响应」。 +参数被剔除后的 payload 会在后续重连中保留,不会重蹈覆辙。 + +重连过程会打印到控制台,例如: +``` +[Rui-Node] 越光: 连接失败(SSLError),1.1s 后重连(第 1/3 次) +[Rui-Node] 越光: 第 2 次重连后成功 +``` +若重连耗尽仍失败,错误信息会注明已重连次数,便于区分「网络确实不通」和「压根没重试」。 + --- ### 19. Markdown转图片 / Markdown To Image @@ -1190,6 +1223,39 @@ Unity/Godot 归一化 pivot(左下为原点):(0.5411, 0.0699) - **自适应参数重试**:部分模型弃用 `temperature`、或要求用 `max_completion_tokens` 取代 `max_tokens`,命中这类 400 时会剔除/改名后自动重试,正常请求零额外开销 - `VALIDATE_INPUTS` 宽松放行:价格表更新后旧工作流里保存的标签不再逐字匹配,但只要能解析出 model id 就放行,不会让整个工作流失效 +**网络自动重连**: +线上跑批时最常见的失败不是参数错,而是链路抖动 —— 典型报错是 +`SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol'))`, +即 TLS 握手/传输被中途打断。这类失败重连一次往往就好了,因此节点内置了重连机制。 + +| 参数 | 默认 | 说明 | +|---|---|---| +| `max_retries` | 3 | 网络失败后的自动重连次数,0 = 不重连 | +| `timeout` | 180 | 单次请求超时秒数,超时计入重连次数 | + +**会重连**:SSL 握手被打断(`SSLEOFError`)、连接被重置、连接/读超时、 +响应体传到一半截断(`ChunkedEncodingError` / JSON 解析失败), +以及 `429` 限流与 `5xx` 服务端临时故障。 + +**不重连**:参数类 `400`、鉴权类 `401/403`、`404` —— 这些重试多少次都是同样结果, +重连只会拖慢报错。 + +**退避策略**:`1s → 2s → 4s → 8s → 16s` 指数增长,每次叠加 ±25% 随机抖动。 +抖动不是可有可无的装饰 —— 一条工作流里常有多个 API 节点同时失败,没有抖动它们会在 +同一毫秒一起重连,把刚缓过来的服务端再打垮一次。服务端返回 `Retry-After` 头时以它为准。 + +**与「自适应参数重试」的关系**:两者是不同层次,互不消耗额度。参数自适应处理的是 +「这个模型不认这个参数」(HTTP 400),网络重连处理的是「没拿到完整响应」。 +参数被剔除后的 payload 会在后续重连中保留,不会重蹈覆辙。 + +重连过程会打印到控制台,例如: +``` +[Rui-Node] 越光: 连接失败(SSLError),1.1s 后重连(第 1/3 次) +[Rui-Node] 越光: 第 2 次重连后成功 +``` +若重连耗尽仍失败,错误信息会注明已重连次数,便于区分「网络确实不通」和「压根没重试」。 + + --- ### 27. 半透明抠图 / Unmult Matting diff --git a/yueguang/yueguang_node.py b/yueguang/yueguang_node.py index f60b078..3887f6a 100644 --- a/yueguang/yueguang_node.py +++ b/yueguang/yueguang_node.py @@ -20,7 +20,9 @@ import base64 import io import json import os +import random import re +import time import numpy as np import requests @@ -65,6 +67,41 @@ def _build_chat_url(base_url: str) -> str: return s + "/chat/completions" +# ── 网络层自动重连 ── +# 可重试的 HTTP 状态:限流与服务端临时故障,换个时间再来往往就好了。 +# 4xx(除 408/429)是参数或鉴权问题,重试纯属浪费,不列入。 +_RETRIABLE_STATUS = {408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524} + +# 这几类异常的共同点是「没拿到完整响应」,重连通常能恢复: +# ConnectionError —— 含 SSLError/SSLEOFError(握手被打断)、连接被重置 +# Timeout —— 连接超时与读超时 +# ChunkedEncodingError / JSONDecodeError —— 响应体传到一半断了 +_RETRIABLE_EXC = ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + json.JSONDecodeError, +) + + +def _retry_wait(attempt: int, resp=None) -> float: + """指数退避 + 随机抖动,返回本次该等几秒。attempt 从 0 起。 + + 抖动不是可有可无的装饰:一条工作流里常有多个 API 节点同时失败, + 没有抖动它们会在同一毫秒一起重连,把刚缓过来的服务端再打垮一次。 + 服务端明确给了 Retry-After 时以它为准。 + """ + if resp is not None: + ra = resp.headers.get("Retry-After") + if ra: + try: + return max(0.5, min(float(ra), 60.0)) + except (TypeError, ValueError): + pass + base = min(2.0 ** attempt, 16.0) # 1s → 2s → 4s → 8s → 16s 封顶 + return round(base * random.uniform(0.75, 1.25), 2) + + def _build_proxy_url(raw_proxy: str) -> str: """把 '127.0.0.1:7890' 或 'http://127.0.0.1:7890' 统一成带协议的地址。""" s = (raw_proxy or "").strip() @@ -217,6 +254,30 @@ class YueGuangNode: "tooltip": "HTTP 代理,同样不要带协议前缀,只填 IP:端口,\n" "例如 127.0.0.1:7890。留空表示直连。" }), + "max_retries": ("INT", { + "default": 3, + "min": 0, + "max": 10, + "step": 1, + "tooltip": "网络失败后的自动重连次数,0 表示不重连。\n\n" + "会触发重连的情况:SSL 握手被打断\n" + "(SSLEOFError)、连接被重置、读超时、响应体\n" + "截断,以及 429 限流和 5xx 服务端临时故障。\n\n" + "不会重连的情况:参数类 400、鉴权类 401/403 ——\n" + "这些重试多少次都是同样的结果。\n\n" + "退避按 1s→2s→4s 指数增长并带随机抖动,避免\n" + "多个节点同时重连再次压垮服务端;服务端给了\n" + "Retry-After 时以它为准。" + }), + "timeout": ("INT", { + "default": 180, + "min": 10, + "max": 1800, + "step": 10, + "tooltip": "单次请求的超时秒数。\n" + "长文本或多图推理较慢时可调大。\n" + "超时会计入上面的重连次数。" + }), "usd_to_cny": ("FLOAT", { "default": 7.2, "min": 0.1, @@ -321,6 +382,8 @@ class YueGuangNode: base_url=DEFAULT_BASE_URL_INPUT, proxy_url="", usd_to_cny=7.2, + max_retries=3, + timeout=180, ): model_id = label_to_model_id(model) or DEFAULT_MODEL_ID chat_url = _build_chat_url(base_url) @@ -382,57 +445,92 @@ class YueGuangNode: 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) + last_net_err = "" + total_tries = max(1, int(max_retries) + 1) + + for attempt in range(total_tries): + try: + # 自适应参数重试:部分模型弃用/不支持某些采样参数, + # 命中即剔除或改名后重试;正常请求不受影响、零额外开销。 + # 这层不消耗网络重连次数——两者是性质不同的问题。 + for _ in range(len(_ADJUSTABLE_PARAMS) + 2): + resp = requests.post(chat_url, headers=headers, json=payload, + proxies=proxies, timeout=timeout) + 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 + + # 限流 / 服务端临时故障:计入网络重连 + if resp.status_code in _RETRIABLE_STATUS and attempt < total_tries - 1: + wait = _retry_wait(attempt, resp) + print(f"[Rui-Node] 越光: 服务端返回 {resp.status_code}," + f"{wait:.1f}s 后重试" + f"(第 {attempt + 1}/{total_tries - 1} 次重连)") + time.sleep(wait) + continue + + 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 "") + if attempt: + print(f"[Rui-Node] 越光: 第 {attempt} 次重连后成功") + 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 _RETRIABLE_EXC as e: + last_net_err = f"{type(e).__name__}: {e}" + if attempt < total_tries - 1: + wait = _retry_wait(attempt) + print(f"[Rui-Node] 越光: 连接失败({type(e).__name__})," + f"{wait:.1f}s 后重连" + f"(第 {attempt + 1}/{total_tries - 1} 次)") + time.sleep(wait) + continue + tail = (f"(已自动重连 {max_retries} 次仍失败;" + f"可调大 max_retries,或检查网络/代理)" + if max_retries else "(max_retries=0,未重连)") + return (f"连接失败(请检查网络/代理): {last_net_err}{tail}", + 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 "") + if attempt: # 重连过仍是这个状态,明说,省得用户以为没重试 + hint += f"(已自动重连 {attempt} 次仍返回该状态)" + return (f"HTTP 错误 {code}: {body}{hint}", model_id, zero_stats) + + except Exception as e: + return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats) + + # 循环内必定 return;这条仅作兜底,避免异常路径返回 None + return ("请求未能完成,请重试或检查服务状态。", model_id, zero_stats) NODE_CLASS_MAPPINGS = { diff --git a/zenmux/zenmux_node.py b/zenmux/zenmux_node.py index de5fab0..44b3b43 100644 --- a/zenmux/zenmux_node.py +++ b/zenmux/zenmux_node.py @@ -23,7 +23,9 @@ import base64 import io import json import os +import random import re +import time import numpy as np import requests @@ -69,6 +71,41 @@ def _build_chat_url(base_url: str) -> str: return s + '/chat/completions' +# ── 网络层自动重连 ── +# 可重试的 HTTP 状态:限流与服务端临时故障,换个时间再来往往就好了。 +# 4xx(除 408/429)是参数或鉴权问题,重试纯属浪费,不列入。 +_RETRIABLE_STATUS = {408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524} + +# 这几类异常的共同点是「没拿到完整响应」,重连通常能恢复: +# ConnectionError —— 含 SSLError/SSLEOFError(握手被打断)、连接被重置 +# Timeout —— 连接超时与读超时 +# ChunkedEncodingError / JSONDecodeError —— 响应体传到一半断了 +_RETRIABLE_EXC = ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + json.JSONDecodeError, +) + + +def _retry_wait(attempt: int, resp=None) -> float: + """指数退避 + 随机抖动,返回本次该等几秒。attempt 从 0 起。 + + 抖动不是可有可无的装饰:一条工作流里常有多个 API 节点同时失败, + 没有抖动它们会在同一毫秒一起重连,把刚缓过来的服务端再打垮一次。 + 服务端明确给了 Retry-After 时以它为准。 + """ + if resp is not None: + ra = resp.headers.get("Retry-After") + if ra: + try: + return max(0.5, min(float(ra), 60.0)) + except (TypeError, ValueError): + pass + base = min(2.0 ** attempt, 16.0) # 1s → 2s → 4s → 8s → 16s 封顶 + return round(base * random.uniform(0.75, 1.25), 2) + + def _build_proxy_url(raw_proxy: str) -> str: """把 '127.0.0.1:7890' 或 'http://127.0.0.1:7890' 统一成带协议的地址。""" s = (raw_proxy or "").strip() @@ -229,6 +266,30 @@ class ZenMuxNode: "例如 127.0.0.1:7890。留空表示直连。" }), # usage_stats 里人民币换算用的汇率,可按当日牌价自行调整 + "max_retries": ("INT", { + "default": 3, + "min": 0, + "max": 10, + "step": 1, + "tooltip": "网络失败后的自动重连次数,0 表示不重连。\n\n" + "会触发重连的情况:SSL 握手被打断\n" + "(SSLEOFError)、连接被重置、读超时、响应体\n" + "截断,以及 429 限流和 5xx 服务端临时故障。\n\n" + "不会重连的情况:参数类 400、鉴权类 401/403 ——\n" + "这些重试多少次都是同样的结果。\n\n" + "退避按 1s→2s→4s 指数增长并带随机抖动,避免\n" + "多个节点同时重连再次压垮服务端;服务端给了\n" + "Retry-After 时以它为准。" + }), + "timeout": ("INT", { + "default": 180, + "min": 10, + "max": 1800, + "step": 10, + "tooltip": "单次请求的超时秒数。\n" + "长文本或多图推理较慢时可调大。\n" + "超时会计入上面的重连次数。" + }), "usd_to_cny": ("FLOAT", { "default": 7.2, "min": 0.1, @@ -334,6 +395,8 @@ class ZenMuxNode: base_url=DEFAULT_BASE_URL, proxy_url="", usd_to_cny=7.2, + max_retries=3, + timeout=180, ): # ---- 1. 从下拉标签解析真实 model id ---- model_id = label_to_model_id(model) or DEFAULT_MODEL_ID @@ -402,56 +465,92 @@ class ZenMuxNode: resp = None dropped = [] # 记录被剔除/改名的参数,供最终报错时提示 - try: - # 自适应参数重试:ZenMux 聚合的部分模型弃用/不支持某些采样参数 - # (claude-sonnet-5 弃用 temperature、gpt-5 reasoning 系要 max_completion_tokens - # 等),命中即剔除/改名后重试,正常请求不受影响、零额外开销。 - 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] ZenMux: 该模型要求 max_completion_tokens," - "已改名重试") - else: - payload.pop(param, None) - dropped.append(param) - print(f"[Rui-Node] ZenMux: 该模型不支持参数 '{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) - # 格式异常:无正文可计字数,但 usage 仍尽量取真实值 - 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),请检查网络或 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 "" - 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) + last_net_err = "" + total_tries = max(1, int(max_retries) + 1) + + for attempt in range(total_tries): + try: + # 自适应参数重试:ZenMux 聚合的部分模型弃用/不支持某些采样参数 + # (claude-sonnet-5 弃用 temperature、gpt-5 reasoning 系要 max_completion_tokens + # 等),命中即剔除/改名后重试,正常请求不受影响、零额外开销。 + # 这层不消耗网络重连次数——两者是性质不同的问题。 + for _ in range(len(_ADJUSTABLE_PARAMS) + 2): + resp = requests.post(chat_url, headers=headers, json=payload, + proxies=proxies, timeout=timeout) + 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] ZenMux: 该模型要求 max_completion_tokens," + "已改名重试") + else: + payload.pop(param, None) + dropped.append(param) + print(f"[Rui-Node] ZenMux: 该模型不支持参数 '{param}'," + "已剔除后重试") + continue + break + + # 限流 / 服务端临时故障:计入网络重连 + if resp.status_code in _RETRIABLE_STATUS and attempt < total_tries - 1: + wait = _retry_wait(attempt, resp) + print(f"[Rui-Node] ZenMux: 服务端返回 {resp.status_code}," + f"{wait:.1f}s 后重试" + f"(第 {attempt + 1}/{total_tries - 1} 次重连)") + time.sleep(wait) + continue + + 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 "") + if attempt: + print(f"[Rui-Node] ZenMux: 第 {attempt} 次重连后成功") + return (content or "", model_id, stats) + # 格式异常:无正文可计字数,但 usage 仍尽量取真实值 + 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 _RETRIABLE_EXC as e: + last_net_err = f"{type(e).__name__}: {e}" + if attempt < total_tries - 1: + wait = _retry_wait(attempt) + print(f"[Rui-Node] ZenMux: 连接失败({type(e).__name__})," + f"{wait:.1f}s 后重连" + f"(第 {attempt + 1}/{total_tries - 1} 次)") + time.sleep(wait) + continue + tail = (f"(已自动重连 {max_retries} 次仍失败;" + f"可调大 max_retries,或检查网络/代理)" + if max_retries else "(max_retries=0,未重连)") + return (f"连接失败(请检查网络/代理): {last_net_err}{tail}", + 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 "" + if attempt: # 重连过仍是这个状态,明说,省得用户以为没重试 + hint += f"(已自动重连 {attempt} 次仍返回该状态)" + return (f"HTTP 错误 {code}: {body}{hint}", model_id, zero_stats) + + except Exception as e: + return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats) + + # 循环内必定 return;这条仅作兜底,避免异常路径返回 None + return ("请求未能完成,请重试或检查服务状态。", model_id, zero_stats) # ────────── ComfyUI 注册 ──────────