Files
yawiii-ComfyUI-Prompt-Assis…/services/openai_base.py
T
2026-01-10 17:38:47 +08:00

495 lines
22 KiB
Python

"""
OpenAI兼容服务基类
为LLM和VLM服务提供统一的OpenAI兼容API处理逻辑
"""
import json
import time
import asyncio
import httpx
from typing import Optional, Dict, Any, List, Callable
from .core import BaseAPIService, HTTPClientPool
from ..utils.common import (
format_api_error, ProgressBar, log_complete, log_error,
PREFIX, PROCESS_PREFIX, WARN_PREFIX, ERROR_PREFIX, format_elapsed_time
)
from .thinking_control import build_thinking_suppression
import re
# ==================== 思维链输出过滤 ====================
def filter_thinking_content(text: str) -> str:
"""
过滤模型输出中的思维链内容
支持多种标签格式:<think>, <reasoning>, <thoughts>
参数:
text: 原始模型输出文本
返回:
str: 过滤后的文本
"""
if not text:
return text
# 1. 优先匹配成对的思维链标签
# 匹配 <think>...</think> 等成对结构
pattern_pair = r'<(think|thinking|reasoning|thoughts?)>[\s\S]*?</\1>'
text = re.sub(pattern_pair, '', text, flags=re.IGNORECASE)
# 2. 兜底处理:如果还有残留的结束标签(可能缺少开始标签),则移除该标签及其之前的所有内容
# 假设:思考过程总是出现在回答的最前面
pattern_orphan_end = r'^[\s\S]*?</(think|thinking|reasoning|thoughts?)>'
text = re.sub(pattern_orphan_end, '', text, flags=re.IGNORECASE)
return text.strip()
class OpenAICompatibleService(BaseAPIService):
"""
OpenAI兼容API服务基类
处理所有OpenAI格式的API请求(智谱、硅基流动、302.ai、Ollama等)
"""
# ---已知的API端点路径(用于智能检测)---
_known_endpoints = ['/chat/completions', '/v1/messages', '/completions']
@staticmethod
def parse_api_url(raw_url: str) -> str:
"""
智能解析 base_url,生成最终请求地址
规则:
1. '#' 结尾 → 强制使用完整地址(移除#)
2. 已包含已知端点路径 → 直接使用,不再拼接
3. 其他 → 正常拼接 /chat/completions
参数:
raw_url: 用户输入的原始URL
返回:
str: 最终请求地址
"""
if not raw_url:
return ''
url = raw_url.strip()
# 规则1:井号强制模式 - 用户明确要求使用完整地址
if url.endswith('#'):
return url[:-1].rstrip('/')
# 规则2:智能检测 - 检查URL中是否已包含已知的API端点
for endpoint in OpenAICompatibleService._known_endpoints:
if endpoint in url:
# 已包含完整端点,直接返回(移除末尾斜杠)
return url.rstrip('/')
# 规则3:常规模式 - 需要拼接 /chat/completions
return url.rstrip('/') + '/chat/completions'
# _provider_base_urls 和 _provider_display_names 已移除,相关逻辑改由 config_manager 统一管理
@staticmethod
def _filter_payload(payload: Dict[str, Any], level: int) -> Dict[str, Any]:
"""
根据重试级别清洗请求体 (简化的三级降级策略)
Level 0: 完整请求 (按用户设置发送)
Level 1: 移除思维链参数 (thinking, enable_thinking, reasoning_effort, 等)
Level 2: 最小可用集 (仅 model, messages, stream)
"""
if level <= 0:
return payload.copy()
filtered = payload.copy()
# Level 1: 移除思维链参数
thinking_keys = [
"thinking", "enable_thinking", "reasoning_effort",
"reasoning", "thinking_level", "think"
]
for k in thinking_keys:
filtered.pop(k, None)
if level >= 2:
# Level 2: 最小可用集 - 仅保留必选参数
core_keys = ["model", "messages", "stream"]
filtered = {k: filtered[k] for k in core_keys if k in filtered}
return filtered
@staticmethod
def _merge_system_prompts(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
合并多条System Message为单一条目
并将System Message置于列表首位
解决部分服务商不支持多条System Message的问题
"""
system_contents = []
other_messages = []
for msg in messages:
if msg.get('role') == 'system':
content = msg.get('content', '')
if content:
system_contents.append(content)
else:
other_messages.append(msg)
if not system_contents:
return messages
# 合并内容
merged_system = "\n\n".join(system_contents)
# 构建新列表:System在首位 + 其他消息
return [{"role": "system", "content": merged_system}] + other_messages
@classmethod
async def _http_request_chat_completions(
cls,
base_url: str,
api_key: str,
model: str,
messages: List[Dict[str, Any]],
temperature: float = 0.7,
top_p: float = 0.9,
max_tokens: int = 2000,
thinking_extra: Optional[Dict[str, Any]] = None,
enable_advanced_params: bool = False,
stream_callback: Optional[Callable[[str], None]] = None,
request_id: Optional[str] = None,
provider_display_name: str = "未知服务",
cancel_event: Optional[Any] = None,
task_type: str = None,
source: str = None
) -> Dict[str, Any]:
"""
使用HTTP直连调用/chat/completions接口
统一处理所有OpenAI兼容的服务商 (支持三级降级重试)
参数:
enable_advanced_params: 是否发送高级参数(temperature/top_p/max_tokens)
"""
from ..server import is_streaming_progress_enabled
try:
# 构建请求URL
url = cls.parse_api_url(base_url)
# 预处理:合并System Prompts (Level 0 默认应用)
merged_messages = cls._merge_system_prompts(messages)
# 构建基础请求体 (仅必选参数)
initial_payload = {
"model": model,
"messages": merged_messages,
"stream": True
}
# 仅在用户开启"启用高级参数"时才发送 temperature、top_p、max_tokens
if enable_advanced_params:
initial_payload["temperature"] = temperature
initial_payload["top_p"] = top_p
initial_payload["max_tokens"] = max_tokens
# 添加思维链控制参数
if thinking_extra:
initial_payload.update(thinking_extra)
# 构建请求头
headers = {"Content-Type": "application/json"}
if api_key and api_key.strip():
headers["Authorization"] = f"Bearer {api_key}"
# 获取HTTP客户端
client = HTTPClientPool.get_client(
provider=provider_display_name,
base_url=base_url,
timeout=60.0
)
# 前置中断检查:如果 ComfyUI 已经中断了,不启动请求
from server import PromptServer
if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted:
return {"success": False, "error": "任务被中断", "interrupted": True}
# 创建统一进度条(自动处理等待→生成→完成的完整生命周期)
pbar = ProgressBar(
request_id=request_id,
service_name=provider_display_name,
streaming=is_streaming_progress_enabled(),
task_type=task_type,
source=source
)
start_time = time.perf_counter()
last_error_msg = ""
# 三级降级重试循环 (Level 0 -> Level 2)
for retry_level in range(3):
current_payload = cls._filter_payload(initial_payload, retry_level)
# 如果不是Level 0,打印降级重试警告(换行输出)
if retry_level > 0:
removed_keys = set(initial_payload.keys()) - set(current_payload.keys())
removed_str = ", ".join(removed_keys) if removed_keys else "无参数变动"
print(f"\n{WARN_PREFIX} ⚠️ HTTP 400错误, 触发Level-{retry_level}降级重试 | 服务:{provider_display_name} | 移除参数:[{removed_str}]", flush=True)
# 关键修复:停止旧的进度条后再创建新的,防止线程泄漏
if pbar:
try:
pbar.error(f"Retry Level {retry_level}...") # 标记前一个进度条为错误/重试状态
except:
pbar._stop_timer()
# 重新创建进度条用于新一轮重试
pbar = ProgressBar(
request_id=request_id,
service_name=provider_display_name,
extra_info=f"Retry-{retry_level}",
streaming=is_streaming_progress_enabled(),
task_type=task_type,
source=source
)
async def _do_stream_request():
nonlocal pbar
# 定义请求核心逻辑
async def _request_core():
async with client.stream('POST', url, headers=headers, json=current_payload, follow_redirects=True) as response:
if response.status_code != 200:
error_text = await response.aread()
try:
error_data = json.loads(error_text)
msg = error_data.get('error', {}).get('message', f'HTTP {response.status_code}')
except:
msg = f'HTTP {response.status_code}: {error_text.decode("utf-8", errors="ignore")[:200]}'
# 智能识别认证错误
from ..utils.common import _is_auth_error
if response.status_code == 401 or _is_auth_error(msg.lower()):
msg = "API Key无效或缺失"
return {
"success": False,
"error": msg,
"status_code": response.status_code,
"should_retry": response.status_code == 400
}
full_content = ""
reasoning_content = ""
async for line in response.aiter_lines():
# 此处的循环检查依然保留,作为双重保险
if cancel_event is not None and cancel_event.is_set():
raise asyncio.CancelledError()
if not line or line == "data: [DONE]" or line == "data:[DONE]": continue
if line.startswith("data: "): line = line[6:]
elif line.startswith("data:"): line = line[5:]
try:
chunk = json.loads(line)
# --- 调试日志 (2级): 输出原始流式数据 ---
# print(f"[DEBUG-2] Chunk: {line[:200]}...", flush=True)
if chunk.get('choices'):
delta = chunk['choices'][0].get('delta', {})
content = delta.get('content', '') or ''
# 针对不同厂商的推理字段进行广谱捕获
reasoning = (
delta.get('reasoning_content', '') or
delta.get('reasoning', '') or
delta.get('thinking', '') or
delta.get('thinking_process', '') or # 备选
''
)
if reasoning: reasoning_content += reasoning
if content:
full_content += content
if stream_callback: stream_callback(content)
pbar.set_generating(len(full_content))
pbar.update(len(full_content))
except:
continue
final_content = full_content
if reasoning_content:
final_content = f"<think>{reasoning_content}</think>\n{full_content}"
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
if not final_content.strip():
pbar.error("响应内容为空")
# --- 调试日志 (1级): 警告响应内容为空 ---
print(f"\n{WARN_PREFIX} [API响应调试] 模型:{model} | 状态:成功 | 但最终内容为空字符串", flush=True)
else:
pbar.done(char_count=len(final_content), elapsed_ms=elapsed_ms)
return {"success": True, "content": final_content}
# 定义监视器逻辑:每100ms检查一次中断信号
async def _monitor_interrupts(target_task):
while not target_task.done():
is_interrupted = False
if cancel_event is not None and cancel_event.is_set():
is_interrupted = True
else:
try:
from server import PromptServer
if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted:
is_interrupted = True
except:
pass
if is_interrupted:
target_task.cancel()
return True
await asyncio.sleep(0.1)
return False
# 并发运行请求和监视器
req_task = asyncio.create_task(_request_core())
monitor_task = asyncio.create_task(_monitor_interrupts(req_task))
try:
result = await req_task
# 关键修复:API 返回错误时,确保进度条被停止
if not result.get("success") and not result.get("interrupted"):
if not getattr(pbar, '_closed', False):
pbar.error(result.get("error", "API 错误"))
return result
except asyncio.CancelledError:
pbar.cancel(f"{WARN_PREFIX} 任务被中断 | 服务:{provider_display_name}")
return {"success": False, "error": "中断", "interrupted": True}
finally:
if not monitor_task.done():
monitor_task.cancel()
# 执行请求
try:
result = await _do_stream_request()
except Exception as req_err:
# 网络层面的异常(非HTTP响应),通常不适合通过参数降级解决,除非确认是特定的协议问题
# 这里选择继续抛出或作为错误返回,不盲目重试
# 但为了稳健,如果是非连接已建立后的错误,可以选择不重试
# 为简单起见,仅记录错误
if 'pbar' in locals() and pbar:
pbar.error(f"网络请求异常: {req_err}")
return {"success": False, "error": f"网络请求异常: {req_err}"}
# 检查结果
if result["success"]:
# Ollama 服务成功后尝试卸载模型
if provider_display_name.lower().find("ollama") != -1:
try:
from ..config_manager import config_manager
service_config = config_manager.get_service(provider_display_name) or {}
await cls._unload_ollama_model(model, service_config)
except:
pass
return result
if result.get("interrupted"):
return result
last_error_msg = result["error"]
# 只有 should_retry 为 True (HTTP 400) 且还有重试机会时,才继续循环
if not result.get("should_retry"):
break # 非400错误(如401, 500等),不进行降级重试,直接返回错误
# 所有重试耗尽或非可重试错误
if 'pbar' in locals() and pbar:
pbar.error(last_error_msg)
return {"success": False, "error": last_error_msg}
# 关键修复:单独捕获 CancelledError,确保进度条被正确停止
except asyncio.CancelledError:
if 'pbar' in locals() and pbar:
pbar.cancel(f"{WARN_PREFIX} 任务被外部取消 | 服务:{provider_display_name}")
return {"success": False, "error": "任务被取消", "interrupted": True}
except Exception as e:
if 'pbar' in locals() and pbar:
pbar.error(format_api_error(e, provider_display_name))
return {"success": False, "error": format_api_error(e, provider_display_name)}
@staticmethod
async def _unload_ollama_model(model: str, provider_config: Dict[str, Any]):
"""
卸载Ollama模型以释放显存和内存
参数:
model: 模型名称
provider_config: 提供商配置字典
"""
try:
# 检查是否启用自动释放
auto_unload = provider_config.get('auto_unload', True)
if not auto_unload:
from ..utils.common import PROCESS_PREFIX
print(f"{PROCESS_PREFIX} Ollama模型已保留 | 模型:{model}")
return
# 获取base_url
base_url = provider_config.get('base_url', 'http://localhost:11434')
if base_url.endswith('/v1'):
base_url = base_url[:-3]
# 调用Ollama API卸载模型
url = f"{base_url}/api/generate"
payload = {
"model": model,
"keep_alive": 0
}
# 创建临时客户端(卸载操作不需要复用)
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(url, json=payload)
if response.status_code == 200:
from ..utils.common import PROCESS_PREFIX
print(f"{PROCESS_PREFIX} Ollama模型已释放 | 模型:{model}")
except Exception as e:
from ..utils.common import WARN_PREFIX
print(f"{WARN_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}")
@classmethod
def get_provider_display_name(cls, provider: str) -> str:
"""
获取提供商显示名称
优先从config_manager获取服务的真实名称,兜底使用provider key
"""
# 优先尝试从config_manager获取服务名称
try:
from ..config_manager import config_manager
service = config_manager.get_service(provider)
if service and 'name' in service:
return service['name']
except Exception:
pass
# 兜底直接返回key
return provider
@classmethod
def get_provider_base_url(cls, provider: str, config: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""
获取提供商的base_url
仅用于custom provider的逻辑,其他情况应直接从config获取
"""
if provider == 'custom' and config:
base_url = config.get('base_url')
# 确保base_url不以/chat/completions结尾
if base_url and base_url.endswith('/chat/completions'):
base_url = base_url[:-len('/chat/completions')]
return base_url
return None