fix: 彻底重写OpenAI节点,根除URL被ComfyUI前端吞掉的问题

核心问题:ComfyUI的前端会将STRING/下拉框中的 :// 及后续内容
当作注释吞掉,导致URL丢失主机名。之前两次修复都没解决,因为
下拉框值 "https://" 本身也含有 :// 。

彻底重写方案 —— 所有控件值中不出现 :// :
- protocol下拉框: ["https", "http"] 纯单词,不含任何特殊字符
- api_url默认值: "api.openai.com/v1/chat/completions" 不含协议
- proxy_url: 用户只需填 IP:端口,代码自动补协议
- _build_full_url() 在纯Python中拼接 "://" ,唯一产生此字符串的位置
- _sanitize_url() 清理各种可能的协议残留碎片
- _build_proxy_url() 智能处理代理地址格式

其他改进:
- 无图像时正常调用API进行纯文本对话
- 细分异常类型:连接失败/超时/HTTP错误分别提示
- 超时时间从60s提升到120s
- 清理 __pycache__ 确保新代码生效

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rui40000
2026-04-01 15:14:38 +08:00
co-authored by Claude Opus 4.6
parent 830e829108
commit 4254f1d887
12 changed files with 131 additions and 142 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+131 -142
View File
@@ -7,85 +7,104 @@ import os
import re
from PIL import Image
# 快速解决方案:清除可能导致连接错误的代理环境变量
# Fast solution: Clear proxy environment variables that might cause connection errors
# 许多用户在使用 requests 库连接 OpenAI API 时会遇到 ProxyError
# 这是因为 Python 环境可能读取了不正确的系统代理设置
# Many users encounter ProxyError when connecting to OpenAI API with requests
# This is because the Python environment might read incorrect system proxy settings
os.environ['HTTP_PROXY'] = ''
os.environ['HTTPS_PROXY'] = ''
os.environ['http_proxy'] = ''
os.environ['https_proxy'] = ''
def _build_url(protocol: str, api_url: str) -> str:
def _clear_proxy_env():
"""
将协议和地址拼接为完整 URL。
兼容多种用户输入格式:
- 'api.openai.com/v1/chat/completions' (标准,无协议前缀)
- 'https://api.openai.com/v1/chat/completions' (用户手动写了完整 URL)
- 'https:api.openai.com/...' (被 ComfyUI 吞掉了 //)
- 'http://127.0.0.1:5000/v1/chat/completions' (本地服务)
清除可能导致 requests 连接错误的代理环境变量。
仅在本模块加载时执行一次。
"""
url = api_url.strip()
for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'):
os.environ.pop(key, None)
# 如果用户手动输入了完整 URL(含协议),直接修复并使用
if re.match(r'^https?://.+', url):
return url
_clear_proxy_env()
# 去掉可能残留的被截断的协议前缀,如 "https:" "http:" "https://" "http://"
url = re.sub(r'^https?:/*', '', url)
# 去掉首尾多余的斜杠
url = url.strip('/')
def _sanitize_url(raw: str) -> str:
"""
清理用户输入的 URL 片段:
去除被 ComfyUI 前端残留的协议碎片、多余斜杠等,只保留 host/path 部分。
"""
s = raw.strip()
# 移除各种可能的协议残留: "https:" / "http:" / "https://" / "http://"
s = re.sub(r'^https?\s*:\s*/*/?\s*', '', s, flags=re.IGNORECASE)
s = s.strip('/')
return s
if not url:
# 全部被吞了,回退到 OpenAI 默认地址
url = 'api.openai.com/v1/chat/completions'
return f"{protocol}{url}"
def _build_full_url(protocol: str, api_url: str) -> str:
"""
用下拉框的协议名和文本框的地址拼出完整 URL。
protocol 只会是 "https" 或 "http"(不含冒号和斜杠)。
"""
host_path = _sanitize_url(api_url)
if not host_path:
host_path = 'api.openai.com/v1/chat/completions'
# 唯一拼接 :// 的地方——纯 Python 字符串,不经过前端
return protocol + '://' + host_path
def _build_proxy_url(raw_proxy: str) -> str:
"""
用户可能输入 '127.0.0.1:7890' 或 'http://127.0.0.1:7890',
统一处理成带协议前缀的地址。
"""
s = raw_proxy.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)
s = s.strip('/')
if not s:
return ''
return 'http://' + s
class OpenAINode:
"""
OpenAI API 节点:
支持连接 OpenAI 及其兼容 API(如 DeepSeek, Moonshot 等),
支持文本生成和多模态图像理解。
OpenAI API 连接节点
==================
支持 OpenAI 及兼容协议的 API(DeepSeek、Moonshot、本地 Ollama 等)。
- 纯文本模式:仅填写 user_prompt,进行对话生成
- 多模态模式:连接 image_1~6,进行图像理解
"""
# ────────── 输入定义 ──────────
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"protocol": (["https://", "http://"], {
"default": "https://"
# 下拉框只含纯英文单词,不含 :// ,杜绝被前端吞掉
"protocol": (["https", "http"], {
"default": "https"
}),
# 默认值不含任何协议前缀,杜绝被前端吞掉
"api_url": ("STRING", {
"default": "api.openai.com/v1/chat/completions",
"multiline": False,
"placeholder": "api.openai.com/v1/chat/completions"
}),
"api_key": ("STRING", {
"default": "",
"multiline": False
"multiline": False,
}),
"model": ("STRING", {
"default": "gpt-4o",
"multiline": False
"multiline": False,
}),
"system_prompt": ("STRING", {
"default": "You are a helpful assistant.",
"multiline": True
"multiline": True,
}),
"user_prompt": ("STRING", {
"default": "",
"multiline": True
"multiline": True,
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff
"max": 0xffffffffffffffff,
}),
},
"optional": {
@@ -99,28 +118,28 @@ class OpenAINode:
"default": 0.3,
"min": 0.0,
"max": 2.0,
"step": 0.1
"step": 0.1,
}),
"max_tokens": ("INT", {
"default": 500,
"min": 1,
"max": 8192
"max": 8192,
}),
"detail": (["low", "high", "auto"], {
"default": "auto"
"default": "auto",
}),
"image_max_size": ("INT", {
"default": 1024,
"min": 256,
"max": 4096,
"step": 64
"step": 64,
}),
# 代理地址也不带协议前缀,只填 IP:端口 即可
"proxy_url": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "e.g., http://127.0.0.1:7890"
}),
}
},
}
RETURN_TYPES = ("STRING",)
@@ -128,21 +147,22 @@ class OpenAINode:
FUNCTION = "generate_content"
CATEGORY = "Rui-Node🐶/AI模型🤖"
def _encode_image_tensor(self, img_tensor, image_max_size):
# ────────── 图像编码 ──────────
@staticmethod
def _encode_image(img_tensor, max_size):
"""将单张图像张量 [H,W,C] 编码为 base64 JPEG 字符串。"""
img_np = img_tensor.cpu().numpy()
img_np = np.clip(img_np, 0, 1)
img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB')
width, height = img_pil.size
if max(width, height) > image_max_size:
ratio = image_max_size / max(width, height)
img_pil = img_pil.resize(
(max(1, int(width * ratio)), max(1, int(height * ratio))),
Image.LANCZOS
)
buffered = io.BytesIO()
img_pil.save(buffered, format="JPEG", quality=85)
return base64.b64encode(buffered.getvalue()).decode('utf-8')
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')
# ────────── 主函数 ──────────
def generate_content(
self,
protocol,
@@ -162,81 +182,50 @@ class OpenAINode:
max_tokens=500,
detail="auto",
image_max_size=1024,
proxy_url=""
proxy_url="",
):
"""
调用 OpenAI API 生成内容。
- 有图像时:使用多模态格式(image_url + text)发送请求
- 无图像时:使用纯文本格式发送请求,实现普通对话
"""
# ---- 1. 拼接 URL(唯一产生 :// 的地方) ----
full_url = _build_full_url(protocol, api_url)
print(f"[Rui-Node] OpenAI -> {full_url}")
# ---- 拼接完整 URL ----
full_url = _build_url(protocol, api_url)
print(f"[Rui-Node🐶 OpenAI] 请求地址: {full_url}")
all_images = [
img for img in [
image_1,
image_2,
image_3,
image_4,
image_5,
image_6,
] if img is not None
# ---- 2. 收集图像 ----
images = [
img for img in (image_1, image_2, image_3, image_4, image_5, image_6)
if img is not None
]
# ---- 构造 messages ----
messages = [
{"role": "system", "content": system_prompt}
]
# ---- 3. 构造 messages ----
messages = [{"role": "system", "content": system_prompt}]
if all_images:
# 多模态模式:图像 + 文本
user_content = []
if user_prompt:
user_content.append({
"type": "text",
"text": user_prompt
})
for image in all_images:
img_tensor = image[0]
img_base64 = self._encode_image_tensor(img_tensor, image_max_size)
user_content.append({
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,{img_base64}",
"detail": detail
}
"url": f"data:image/jpeg;base64,{b64}",
"detail": detail,
},
})
if not user_content:
user_content.append({
"type": "text",
"text": " "
})
messages.append({
"role": "user",
"content": user_content
})
if not parts:
parts.append({"type": "text", "text": " "})
messages.append({"role": "user", "content": parts})
else:
# 纯文本模式:无图像,直接发送文本对话
prompt_text = user_prompt.strip() if user_prompt else ""
if not prompt_text:
return ("(错误:未提供图片也未提供提示词,请至少输入 user_prompt)",)
messages.append({
"role": "user",
"content": prompt_text
})
# ===== 纯文本模式 =====
text = (user_prompt or "").strip()
if not text:
return ("(错误:未提供图片也未提供提示词,请至少填写 user_prompt)",)
messages.append({"role": "user", "content": text})
# ---- 4. 请求 ----
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
"Authorization": f"Bearer {api_key}",
}
payload = {
"model": model,
"messages": messages,
@@ -246,32 +235,32 @@ class OpenAINode:
}
proxies = None
if proxy_url and proxy_url.strip():
proxies = {
"http": proxy_url,
"https": proxy_url
}
p = _build_proxy_url(proxy_url)
if p:
proxies = {"http": p, "https": p}
try:
response = requests.post(full_url, headers=headers, json=payload, proxies=proxies, timeout=60)
response.raise_for_status()
result = response.json()
if "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"]["content"]
return (content,)
else:
return (f"Error: API response format unexpected. Response: {json.dumps(result)}",)
resp = requests.post(full_url, headers=headers, json=payload,
proxies=proxies, timeout=120)
resp.raise_for_status()
data = resp.json()
if "choices" in data and data["choices"]:
return (data["choices"][0]["message"]["content"],)
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)}",)
except requests.exceptions.ConnectionError as e:
return (f"连接失败(请检查 api_url 和网络): {e}",)
except requests.exceptions.Timeout:
return ("请求超时(120s),请检查网络或 API 服务状态。",)
except requests.exceptions.HTTPError as e:
return (f"HTTP 错误 {resp.status_code}: {resp.text[:500]}",)
except Exception as e:
return (f"Error calling OpenAI API: {str(e)}",)
return (f"请求异常: {type(e).__name__}: {e}",)
# 节点映射字典
# ────────── ComfyUI 注册 ──────────
NODE_CLASS_MAPPINGS = {
"OpenAIAPINode": OpenAINode
"OpenAIAPINode": OpenAINode,
}
# 节点显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAIAPINode": "OpenAI API 连接 / OpenAI API Connector"
"OpenAIAPINode": "OpenAI API 连接 / OpenAI API Connector",
}