Files
jinchan 9e6854c3d0 refactor(core): 重构和拆分核心模块,新增本地API客户端与工具
- 新增 local_api_client.py,提供统一同步HTTP调用类和API端点定义
- 新增 local_api_models.py,定义OpenAI图像生成及响应数据模型
- 新增 local_api_utils.py,包含图像张量处理、下载重试及API响应验证等工具
- 优化 node.py,移除旧实现,改为导入拆分后的模块,提高代码可维护性
- .gitignore中添加doc/目录忽略规则
2026-08-10 18:23:59 +08:00

168 lines
5.8 KiB
Python

from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, Optional, Type
from urllib.parse import urljoin
import asyncio
import time
import requests
from pydantic import BaseModel
# 与旧版 comfy_api_nodes client 保持一致的默认值
DEFAULT_TIMEOUT = 7200
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 1.0
DEFAULT_RETRY_BACKOFF = 2.0
class HttpMethod(str, Enum):
GET = "GET"
POST = "POST"
@dataclass
class ApiEndpoint:
path: str
method: HttpMethod
request_model: Optional[Type[BaseModel]] = None
response_model: Optional[Type[BaseModel]] = None
class SynchronousOperation:
def __init__(
self,
api_base: str,
endpoint: ApiEndpoint,
request: Optional[BaseModel] = None,
files: Optional[Any] = None,
content_type: str = "application/json",
auth_kwargs: Optional[Dict[str, Any]] = None,
):
self.api_base = (api_base or "").rstrip("/")
self.endpoint = endpoint
self.request = request
self.files = files
self.content_type = content_type or "application/json"
self.auth_kwargs = auth_kwargs or {}
def _build_url(self) -> str:
if not self.api_base:
raise ValueError("api_base 不能为空")
path = self.endpoint.path if self.endpoint.path.startswith("/") else f"/{self.endpoint.path}"
return urljoin(f"{self.api_base}/", path.lstrip("/"))
def _build_headers(self) -> Dict[str, str]:
headers: Dict[str, str] = {"Accept": "application/json"}
auth_token = (
self.auth_kwargs.get("auth_token")
or self.auth_kwargs.get("api_key")
or self.auth_kwargs.get("comfy_api_key")
)
if isinstance(auth_token, str) and auth_token.strip():
headers["Authorization"] = f"Bearer {auth_token.strip()}"
if self.files:
return headers
headers["Content-Type"] = self.content_type
return headers
def _request_payload(self) -> Dict[str, Any]:
if self.request is None:
return {}
if hasattr(self.request, "model_dump"):
return self.request.model_dump(exclude_none=True)
return dict(self.request)
def _execute_sync(self):
url = self._build_url()
method = self.endpoint.method
headers = self._build_headers()
payload = self._request_payload()
timeout = self.auth_kwargs.get("timeout", DEFAULT_TIMEOUT)
verify_ssl = self.auth_kwargs.get("verify", True)
response = self._send_with_retries(method, url, headers, payload, timeout, verify_ssl)
try:
data = response.json()
except ValueError:
data = {"raw": response.text}
response_model = self.endpoint.response_model
if response_model:
if hasattr(response_model, "model_validate"):
return response_model.model_validate(data)
return response_model(**data)
return data
def _send_once(self, method, url, headers, payload, timeout, verify_ssl):
if method == HttpMethod.GET:
return requests.get(url, headers=headers, params=payload, timeout=timeout, verify=verify_ssl)
if method == HttpMethod.POST:
if self.files:
# 多段上传前需要把文件流指针复位,否则重试时会发出空文件
self._rewind_files()
return requests.post(
url,
headers=headers,
data=payload,
files=self.files,
timeout=timeout,
verify=verify_ssl,
)
return requests.post(url, headers=headers, json=payload, timeout=timeout, verify=verify_ssl)
raise ValueError(f"不支持的HTTP方法: {method}")
def _rewind_files(self) -> None:
file_iter = self.files if isinstance(self.files, list) else self.files.items()
for _, file_value in file_iter:
stream = file_value[1] if isinstance(file_value, (tuple, list)) else file_value
if hasattr(stream, "seek"):
stream.seek(0)
def _send_with_retries(self, method, url, headers, payload, timeout, verify_ssl):
delay = DEFAULT_RETRY_DELAY
last_error: Optional[Exception] = None
for attempt in range(DEFAULT_MAX_RETRIES):
try:
response = self._send_once(method, url, headers, payload, timeout, verify_ssl)
except requests.RequestException as exc:
last_error = exc
else:
if response.status_code < 400:
return response
error = RuntimeError(
f"API 请求失败: {response.status_code} {response.reason} "
f"{method.value if hasattr(method, 'value') else method} {url}\n"
f"响应内容: {self._response_snippet(response)}"
)
# 4xx 属于确定性失败,重试没有意义
if response.status_code < 500:
raise error
last_error = error
if attempt < DEFAULT_MAX_RETRIES - 1:
time.sleep(delay)
delay *= DEFAULT_RETRY_BACKOFF
raise last_error if last_error else RuntimeError(f"API 请求失败: {url}")
@staticmethod
def _response_snippet(response, limit: int = 2000) -> str:
try:
text = response.text or ""
except Exception:
return "<无法读取响应内容>"
return text[:limit] + ("...(已截断)" if len(text) > limit else "")
async def execute(self):
return await asyncio.to_thread(self._execute_sync)