Files
2026-04-25 10:38:46 +08:00

570 lines
27 KiB
Python
Raw Permalink 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.
"""
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 .ollama_utils import wait_before_ollama_unload
from ..utils.common import (
format_api_error, ProgressBar, log_complete, log_error,
PREFIX, PROCESS_PREFIX, WARN_PREFIX, ERROR_PREFIX, format_elapsed_time,
TASK_IMAGE_CAPTION, TASK_VIDEO_CAPTION
)
from .thinking_control import build_thinking_suppression
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('/')
if 'api.openai.com' in url and '/v1' not in url:
url = url.rstrip('/') + '/v1'
# 规则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: 移除思维链参数 + response_format
# 注:response_format 在 Zhipu GLM-4V 等严格服务商上会触发 400,Level-1 同步移除
thinking_keys = [
"thinking", "enable_thinking", "reasoning_effort",
"reasoning", "thinking_level", "think",
"response_format", # BUG-02 修复:Level-1 一并移除,防止部分服务商拒绝该字段
]
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,
filter_thinking_output: bool = True
) -> Dict[str, Any]:
"""
使用HTTP直连调用/chat/completions接口
统一处理所有OpenAI兼容的服务商 (支持三级降级重试)
参数:
enable_advanced_params: 是否发送高级参数(temperature/top_p/max_tokens)
"""
from ..server import is_streaming_progress_enabled
try:
# 清理输入参数
base_url = cls._sanitize_config_str(base_url)
api_key = cls._sanitize_config_str(api_key)
# 构建请求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
}
# 视觉任务(图像/视频反推)对于某些模型严格要求带有 max_tokens,否则会触发 openai_error,兼容 V1
is_vision_task = task_type in [TASK_IMAGE_CAPTION, TASK_VIDEO_CAPTION] if task_type else False
# 仅在用户开启"启用高级参数"时才发送 temperature、top_p、max_tokens(视觉任务强制发送)
if enable_advanced_params or is_vision_task:
initial_payload["temperature"] = temperature
initial_payload["top_p"] = top_p
if is_vision_task and not enable_advanced_params:
# BUG-07 修复:视觉任务强制发送 max_tokens 时,使用保守上限
# 部分中转站(如 Gemini Flash via 代理)的 max_tokens 上限为 1024-1500
# 采用 min(max_tokens, 1500) 作为兜底,降低兼容性问题
initial_payload["max_tokens"] = min(max_tokens, 1500)
else:
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客户端
request_timeout = 180.0
if task_type in (TASK_IMAGE_CAPTION, TASK_VIDEO_CAPTION):
request_timeout = 300.0
client = HTTPClientPool.get_client(
provider=provider_display_name,
base_url=base_url,
timeout=request_timeout
)
# 前置中断检查:如果 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 = ""
stream_error = None # 用于捕获流内错误
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)
# 关键修复:检测流内错误(部分代理以HTTP 200+SSE方式返回错误)
if chunk.get('error'):
err = chunk['error']
if isinstance(err, dict):
stream_error = err.get('message', str(err))
else:
stream_error = str(err)
break # 立即中止流读取
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
progress_count = len(full_content) + len(reasoning_content)
pbar.set_generating(progress_count)
pbar.update(progress_count)
if content:
full_content += content
if stream_callback: stream_callback(content)
progress_count = len(full_content) + len(reasoning_content)
pbar.set_generating(progress_count)
pbar.update(progress_count)
except json.JSONDecodeError:
continue # 非 JSON 行(如注释、空行),正常跳过
except asyncio.CancelledError:
raise # 必须重新抛出,不能被吐掉
except Exception as chunk_err:
# 其他异常:记录警告后继续,避免因单块错误中断整个流
print(f"\n{WARN_PREFIX} SSE 块解析异常: {chunk_err}", flush=True)
continue
# 如果流中检测到错误,动态判断是否应该触发降级重试
if stream_error:
# BUG-05 修复: 部分代理(xFlow/Grok等)以 HTTP 200+SSE error 返回参数错误时
# 应当触发降级重试,而非直接永久失败
_RETRYABLE_STREAM_ERROR_KEYWORDS = [
"unsupported", "invalid parameter", "invalid_request",
"unknown field", "extra inputs", "not supported",
"unrecognized", "unexpected", "disallowed",
"does not support"
]
stream_error_lower = stream_error.lower()
is_retryable_stream_error = any(
kw in stream_error_lower
for kw in _RETRYABLE_STREAM_ERROR_KEYWORDS
)
return {
"success": False,
"error": stream_error,
"status_code": 200, # HTTP层面成功,业务层面失败
"should_retry": is_retryable_stream_error # 动态判断是否应该降级重试
}
final_content = full_content
# 只有当用户没有开启“过滤思维链”时,才手动把 reasoning_content 拼回去
if reasoning_content and not filter_thinking_output:
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)
return {
"success": False,
"error": "API returned empty content",
"status_code": 200,
"should_retry": True
}
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响应)
# 特别处理编码错误 (UnicodeEncodeError)
if isinstance(req_err, UnicodeEncodeError):
error_detail = "网络请求编码异常: 检测到非法字符 (\u2026 或其他非 ASCII 字符)。请检查服务商配置中的 API Key 或 URL 是否包含多余的省略号、引号或空格。"
if 'pbar' in locals() and pbar:
pbar.error(error_detail)
return {"success": False, "error": error_detail}
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 {}
# 注入当前实际使用的 base_url,防止回退到 localhost 导致清理显存失败
service_config['base_url'] = base_url
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
await wait_before_ollama_unload()
# 获取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
}
# 创建临时客户端(卸载操作不需要复用,禁用代理避免localhost请求被拦截)
async with httpx.AsyncClient(timeout=5.0, trust_env=False) 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
@staticmethod
def _sanitize_config_str(s: str) -> str:
"""
清理配置字符串中的非法字符(如空格、引号、不可见字符等)
防止因复制粘贴导致的请求失败。
关键修复:传入 None 或非字符串时统一返回空字符串 "",
避免后续拼接产生 "Bearer None" 等无效认证头。
"""
if not s or not isinstance(s, str):
return "" # 统一返回空字符串,不返回 None
# 1. 移除两端空格和常见引号
s = s.strip().strip('"').strip("'")
# 2. 移除常见的 Unicode 干扰字符(如 \u2026 省略号)
# 注意:这里我们选择将其替换为空,因为 API Key 中不应包含这些字符
s = s.replace('\u2026', '')
# 3. 移除不可见的制表符和换行符
s = s.replace('\t', '').replace('\n', '').replace('\r', '')
return s