625 lines
23 KiB
Python
Executable File
625 lines
23 KiB
Python
Executable File
"""
|
||
GrsAI API客户端
|
||
封装与grsai.com的所有交互逻辑
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
import requests
|
||
from typing import Optional, Dict, Any, List, Tuple, TYPE_CHECKING
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
|
||
if TYPE_CHECKING:
|
||
from PIL import Image
|
||
|
||
try:
|
||
from .config import GrsaiConfig, default_config
|
||
from .http_client import (
|
||
create_http_session,
|
||
describe_request_error,
|
||
normalize_timeout,
|
||
)
|
||
from .utils import format_error_message, download_image
|
||
except ImportError:
|
||
from config import GrsaiConfig, default_config
|
||
from http_client import (
|
||
create_http_session,
|
||
describe_request_error,
|
||
normalize_timeout,
|
||
)
|
||
from utils import format_error_message, download_image
|
||
|
||
|
||
class GrsaiAPIError(Exception):
|
||
"""API调用异常"""
|
||
|
||
pass
|
||
|
||
|
||
class GrsaiAPI:
|
||
"""GrsAI API客户端类"""
|
||
|
||
def __init__(self, api_key: str, config: Optional[GrsaiConfig] = None):
|
||
"""
|
||
初始化API客户端
|
||
|
||
Args:
|
||
api_key: API密钥
|
||
config: 配置对象
|
||
"""
|
||
if not api_key or not api_key.strip():
|
||
raise GrsaiAPIError("API密钥在初始化时不能为空")
|
||
normalized_api_key = api_key.strip()
|
||
if not normalized_api_key.startswith("sk-"):
|
||
raise GrsaiAPIError(
|
||
"API密钥格式无效:请输入以 sk- 开头的真实 GrsAI API Key,"
|
||
"不要使用输入框中的中文占位文本"
|
||
)
|
||
try:
|
||
normalized_api_key.encode("ascii")
|
||
except UnicodeEncodeError as exc:
|
||
raise GrsaiAPIError(
|
||
"API密钥格式无效:密钥中包含中文或其他非 ASCII 字符," "请求尚未提交"
|
||
) from exc
|
||
|
||
self.api_key = normalized_api_key
|
||
self.config = config or default_config
|
||
self.session = create_http_session()
|
||
self.last_task_id: Optional[str] = None
|
||
self._setup_session()
|
||
|
||
@staticmethod
|
||
def _extract_error_detail(payload: Any) -> str:
|
||
"""从接口错误响应中提取最具体的可读失败原因。"""
|
||
if payload is None:
|
||
return ""
|
||
if isinstance(payload, str):
|
||
return payload.strip()
|
||
if isinstance(payload, dict):
|
||
details: List[str] = []
|
||
for key in ("error", "message", "msg", "detail", "reason"):
|
||
if key in payload:
|
||
detail = GrsaiAPI._extract_error_detail(payload[key])
|
||
if detail and detail not in details:
|
||
details.append(detail)
|
||
return "; ".join(details)
|
||
if isinstance(payload, list):
|
||
details = [GrsaiAPI._extract_error_detail(item) for item in payload]
|
||
return "; ".join(detail for detail in details if detail)
|
||
return str(payload).strip()
|
||
|
||
def _setup_session(self):
|
||
"""设置HTTP会话"""
|
||
self.session.headers.update(
|
||
{
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
"User-Agent": "ComfyUI-GrsAI/1.1.5",
|
||
}
|
||
)
|
||
|
||
# 直接使用传入的API密钥设置认证头
|
||
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
|
||
|
||
def _make_request(
|
||
self,
|
||
method: str,
|
||
endpoint: str,
|
||
data: Optional[Dict] = None,
|
||
params: Optional[Dict] = None,
|
||
timeout: Optional[int] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
发送HTTP请求
|
||
|
||
Args:
|
||
method: HTTP方法
|
||
endpoint: API端点
|
||
data: JSON 请求数据
|
||
params: URL 查询参数
|
||
timeout: 超时时间
|
||
|
||
Returns:
|
||
Dict: API响应数据
|
||
|
||
Raises:
|
||
GrsaiAPIError: API调用失败
|
||
"""
|
||
url = f"{self.config.get_config('api_base_url')}{endpoint}"
|
||
timeout = timeout or self.config.get_config("request_timeout", 60)
|
||
request_timeout = normalize_timeout(timeout)
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
}
|
||
|
||
try:
|
||
if method.upper() == "POST":
|
||
response = self.session.post(
|
||
url,
|
||
json=data,
|
||
params=params,
|
||
timeout=request_timeout,
|
||
headers=headers,
|
||
)
|
||
else:
|
||
response = self.session.get(
|
||
url,
|
||
params=params,
|
||
timeout=request_timeout,
|
||
headers=headers,
|
||
)
|
||
|
||
# 检查HTTP状态码
|
||
if 200 <= response.status_code < 300:
|
||
json_data = (
|
||
response.text[6:]
|
||
if response.text.startswith("data: ")
|
||
else response.text
|
||
)
|
||
try:
|
||
result = json.loads(json_data)
|
||
except (TypeError, json.JSONDecodeError) as exc:
|
||
raise GrsaiAPIError("API返回了无法解析的JSON响应") from exc
|
||
if not isinstance(result, dict):
|
||
raise GrsaiAPIError(
|
||
f"API响应格式错误: 期望字典,实际为 {type(result).__name__}"
|
||
)
|
||
return result
|
||
|
||
error_detail = ""
|
||
try:
|
||
error_detail = self._extract_error_detail(response.json())
|
||
except (ValueError, TypeError):
|
||
error_detail = response.text.strip()
|
||
if len(error_detail) > 1000:
|
||
error_detail = error_detail[:997] + "..."
|
||
|
||
if response.status_code == 401:
|
||
error_msg = error_detail or "API密钥无效或已过期"
|
||
elif response.status_code == 429:
|
||
error_msg = error_detail or "请求频率过高"
|
||
elif response.status_code >= 500:
|
||
error_msg = error_detail or "接口服务器内部错误"
|
||
else:
|
||
error_msg = error_detail or "接口未提供具体错误信息"
|
||
raise GrsaiAPIError(f"API请求失败 ({response.status_code}): {error_msg}")
|
||
|
||
except requests.RequestException as exc:
|
||
raise GrsaiAPIError(describe_request_error(exc, "API 请求")) from exc
|
||
except UnicodeEncodeError as exc:
|
||
raise GrsaiAPIError(
|
||
"请求未提交:请求头包含无法编码的字符,请检查 API Key 是否误填了中文"
|
||
) from exc
|
||
except GrsaiAPIError:
|
||
raise
|
||
except Exception as e:
|
||
raise GrsaiAPIError(format_error_message(e, "网络请求"))
|
||
|
||
def _raise_for_task_failure(self, response: Dict[str, Any]) -> None:
|
||
"""将异步任务的失败状态转换为统一异常。"""
|
||
status = str(response.get("status", "unknown"))
|
||
task_id = response.get("id", "unknown")
|
||
error = self._extract_error_detail(response) or "服务未提供错误详情"
|
||
raise GrsaiAPIError(f"异步生成失败 (任务: {task_id}, 状态: {status}): {error}")
|
||
|
||
def _wait_for_async_result(
|
||
self, initial_response: Dict[str, Any]
|
||
) -> Dict[str, Any]:
|
||
"""轮询新版异步结果接口,直到任务成功、失败或超时。"""
|
||
task_id = initial_response.get("id")
|
||
if not isinstance(task_id, str) or not task_id.strip():
|
||
raise GrsaiAPIError(f"异步生成响应缺少有效任务ID: {initial_response}")
|
||
|
||
response = initial_response
|
||
poll_interval = max(0.5, float(self.config.get_config("poll_interval", 2.0)))
|
||
generation_timeout = max(
|
||
poll_interval,
|
||
float(self.config.get_config("generation_timeout", 3600)),
|
||
)
|
||
deadline = time.monotonic() + generation_timeout
|
||
waiting_statuses = {"running", "pending", "queued", "processing", "submitted"}
|
||
failed_statuses = {"failed", "violation", "cancelled", "canceled"}
|
||
last_progress = None
|
||
|
||
while True:
|
||
status = str(response.get("status", "")).lower()
|
||
if status == "succeeded":
|
||
return response
|
||
if status in failed_statuses:
|
||
self._raise_for_task_failure(response)
|
||
if status not in waiting_statuses:
|
||
raise GrsaiAPIError(
|
||
f"异步任务返回未知状态 (任务: {task_id}): {status or '空状态'}"
|
||
)
|
||
|
||
progress = response.get("progress")
|
||
if progress is not None and progress != last_progress:
|
||
print(f"⏳ 异步任务 {task_id} 生成进度: {progress}%")
|
||
last_progress = progress
|
||
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
raise GrsaiAPIError(
|
||
f"异步生成超时 (任务: {task_id}, 等待: {generation_timeout:g}秒)"
|
||
)
|
||
|
||
time.sleep(min(poll_interval, remaining))
|
||
response = self._make_request(
|
||
"GET",
|
||
"/v1/api/result",
|
||
params={"id": task_id},
|
||
)
|
||
|
||
@staticmethod
|
||
def _extract_result_urls(response: Dict[str, Any]) -> List[str]:
|
||
"""从新版 results 数组中提取有效的媒体 URL。"""
|
||
results = response.get("results")
|
||
if not isinstance(results, list):
|
||
raise GrsaiAPIError("异步任务成功,但响应中缺少 results 列表")
|
||
|
||
urls = [
|
||
item.get("url")
|
||
for item in results
|
||
if isinstance(item, dict)
|
||
and isinstance(item.get("url"), str)
|
||
and item["url"].startswith(("http://", "https://"))
|
||
]
|
||
if not urls:
|
||
raise GrsaiAPIError("异步任务成功,但 results 中没有有效媒体 URL")
|
||
return urls
|
||
|
||
def _download_images(
|
||
self, urls: List[str]
|
||
) -> Tuple[List["Image.Image"], List[str], List[str]]:
|
||
"""并行下载结果图片,并保持 API 返回顺序。"""
|
||
downloaded: Dict[int, Tuple["Image.Image", str]] = {}
|
||
errors: List[str] = []
|
||
timeout = self.config.get_config("timeout", 300)
|
||
|
||
def download_one(index: int, url: str):
|
||
image = download_image(url, timeout=timeout, raise_on_error=True)
|
||
if image is None:
|
||
raise GrsaiAPIError("图像下载失败:未返回有效图片")
|
||
return index, image, url
|
||
|
||
with ThreadPoolExecutor(max_workers=min(len(urls), 8)) as executor:
|
||
futures = {
|
||
executor.submit(download_one, index, url): (index, url)
|
||
for index, url in enumerate(urls)
|
||
}
|
||
for future in as_completed(futures):
|
||
index, url = futures[future]
|
||
try:
|
||
_, image, image_url = future.result()
|
||
downloaded[index] = (image, image_url)
|
||
except Exception as exc:
|
||
errors.append(f"图片下载失败: {url} ({exc})")
|
||
|
||
ordered = [downloaded[index] for index in sorted(downloaded)]
|
||
return (
|
||
[item[0] for item in ordered],
|
||
[item[1] for item in ordered],
|
||
errors,
|
||
)
|
||
|
||
def _generate_async(
|
||
self, payload: Dict[str, Any]
|
||
) -> Tuple[List["Image.Image"], List[str], List[str]]:
|
||
"""通过新版统一接口提交异步任务、轮询并下载结果。"""
|
||
self.last_task_id = None
|
||
async_payload = {
|
||
key: value
|
||
for key, value in payload.items()
|
||
if value is not None and value != ""
|
||
}
|
||
async_payload["replyType"] = "async"
|
||
model = str(async_payload.get("model", "unknown"))
|
||
image_count = len(async_payload.get("images", []))
|
||
task_id = "未创建"
|
||
request_details = {
|
||
key: value
|
||
for key, value in async_payload.items()
|
||
if key not in {"prompt", "images", "mask"}
|
||
}
|
||
|
||
print(
|
||
f"🚀 提交新版异步图片生成 | 模型: {model} | "
|
||
f"参考图: {image_count} 张 | 参数: {request_details}"
|
||
)
|
||
|
||
try:
|
||
response = self._make_request(
|
||
"POST",
|
||
"/v1/api/generate",
|
||
data=async_payload,
|
||
)
|
||
task_id = str(response.get("id", "未返回"))
|
||
if task_id and task_id != "未返回":
|
||
self.last_task_id = task_id
|
||
print(
|
||
f"📨 异步任务提交成功 | 任务: {task_id} | "
|
||
f"状态: {response.get('status', 'unknown')}"
|
||
)
|
||
|
||
completed = self._wait_for_async_result(response)
|
||
result_urls = self._extract_result_urls(completed)
|
||
print(
|
||
f"✅ 图片生成成功 | 模型: {model} | 任务: {task_id} | "
|
||
f"结果: {len(result_urls)} 张 | 进度: {completed.get('progress', 100)}%"
|
||
)
|
||
for index, url in enumerate(result_urls, 1):
|
||
print(f" 结果 {index}: {url}")
|
||
|
||
images, image_urls, errors = self._download_images(result_urls)
|
||
for error in errors:
|
||
print(f"❌ {error}")
|
||
|
||
if errors:
|
||
print(
|
||
f"⚠️ 结果下载完成 | 任务: {task_id} | "
|
||
f"成功: {len(images)} 张 | 失败: {len(errors)} 张"
|
||
)
|
||
else:
|
||
print(
|
||
f"✅ 结果下载成功 | 任务: {task_id} | " f"已下载: {len(images)} 张"
|
||
)
|
||
return images, image_urls, errors
|
||
except Exception as exc:
|
||
print(
|
||
f"❌ 图片生成失败 | 模型: {model} | 任务: {task_id} | "
|
||
f"错误详情: {exc}"
|
||
)
|
||
raise
|
||
|
||
def gpt_image_generate_image(
|
||
self,
|
||
prompt: str,
|
||
model: str = "gpt-image-2",
|
||
aspect_ratio: Optional[str] = None,
|
||
urls: Optional[List[str]] = None,
|
||
quality: Optional[str] = None,
|
||
background: Optional[str] = None,
|
||
mask: Optional[str] = None,
|
||
) -> Tuple[List["Image.Image"], List[str], List[str]]:
|
||
"""使用新版统一异步接口生成 GPT Image 图片。"""
|
||
if quality is None:
|
||
vip_parameter_models = {
|
||
"gpt-image-2-vip",
|
||
"gpt-image-2.5-flare",
|
||
"gpt-image-2.5-sunburst",
|
||
}
|
||
quality = "medium" if model in vip_parameter_models else "auto"
|
||
payload = {
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"images": urls or [],
|
||
"aspectRatio": aspect_ratio,
|
||
"quality": quality,
|
||
"background": background,
|
||
"mask": mask,
|
||
}
|
||
try:
|
||
return self._generate_async(payload)
|
||
except Exception as e:
|
||
if isinstance(e, GrsaiAPIError):
|
||
raise
|
||
raise GrsaiAPIError(format_error_message(e, "图像生成"))
|
||
|
||
def banana_generate_image(
|
||
self,
|
||
prompt: str,
|
||
model: str = "nano-banana-fast",
|
||
urls: Optional[List[str]] = None,
|
||
aspect_ratio: Optional[str] = None,
|
||
image_size: Optional[str] = None,
|
||
) -> Tuple[List["Image.Image"], List[str], List[str]]:
|
||
"""
|
||
Nano Banana API 调用
|
||
|
||
Args:
|
||
prompt: 编辑或生成描述。
|
||
model: 使用的模型,默认 "nano-banana-fast"。
|
||
可选值:"nano-banana-fast"、"nano-banana"、"nano-banana-pro"、"nano-banana-pro-vt"。
|
||
urls: 可选的参考/输入图片 URL 列表(用于编辑场景)。
|
||
image_size: 仅 nano-banana-pro / nano-banana-pro-vt 支持的输出尺寸,可选 "1K" | "2K" | "4K"。
|
||
|
||
Returns:
|
||
(pil_images, image_urls, errors)
|
||
"""
|
||
payload = {
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"images": urls or [],
|
||
}
|
||
|
||
if image_size:
|
||
# ComfyUI 侧无法基于模型动态隐藏 imageSize 参数,因此在非支持模型上直接忽略该参数
|
||
if default_config.nano_banana_model_supports_image_size(model):
|
||
if not default_config.validate_nano_banana_image_size(image_size):
|
||
raise GrsaiAPIError(
|
||
f"不支持的 imageSize: {image_size}. 支持的选项: {', '.join(default_config.SUPPORTED_NANO_BANANA_SIZES)}"
|
||
)
|
||
payload["imageSize"] = image_size
|
||
|
||
if aspect_ratio:
|
||
if not default_config.validate_nano_banana_aspect_ratio(aspect_ratio):
|
||
raise GrsaiAPIError(
|
||
f"不支持的宽高比: {aspect_ratio}. 支持的选项: {', '.join(default_config.SUPPORTED_NANO_BANANA_AR)}"
|
||
)
|
||
payload["aspectRatio"] = aspect_ratio
|
||
|
||
try:
|
||
return self._generate_async(payload)
|
||
except Exception as e:
|
||
if isinstance(e, GrsaiAPIError):
|
||
raise
|
||
raise GrsaiAPIError(format_error_message(e, "Nano Banana 调用"))
|
||
|
||
def minimax_h3_generate_video(
|
||
self,
|
||
prompt: str,
|
||
aspect_ratio: str,
|
||
resolution: str,
|
||
duration: int,
|
||
images: Optional[List[str]] = None,
|
||
audios: Optional[List[str]] = None,
|
||
seed: Optional[int] = None,
|
||
) -> List[str]:
|
||
"""提交 MiniMax H3 异步视频任务并返回生成结果 URL。"""
|
||
self.last_task_id = None
|
||
if not prompt or not prompt.strip():
|
||
raise GrsaiAPIError("视频提示词不能为空")
|
||
if aspect_ratio not in {"portrait", "landscape"}:
|
||
raise GrsaiAPIError("视频比例仅支持 portrait 或 landscape")
|
||
if resolution not in {"480p", "768p", "1080p"}:
|
||
raise GrsaiAPIError("视频分辨率仅支持 480p、768p 或 1080p")
|
||
if not 1 <= duration <= 15:
|
||
raise GrsaiAPIError("视频时长必须在 1 到 15 秒之间")
|
||
if resolution == "1080p" and duration > 10:
|
||
raise GrsaiAPIError("1080p 视频时长不能超过 10 秒")
|
||
if seed is not None and not 0 <= seed <= 9999999999:
|
||
raise GrsaiAPIError("视频随机种子必须是最多 10 位的非负整数")
|
||
|
||
image_inputs = images or []
|
||
audio_inputs = audios or []
|
||
if len(image_inputs) > 9:
|
||
raise GrsaiAPIError("MiniMax H3 最多支持 9 张参考图")
|
||
if len(audio_inputs) > 3:
|
||
raise GrsaiAPIError("MiniMax H3 最多支持 3 段参考音频")
|
||
|
||
payload: Dict[str, Any] = {
|
||
"model": "minimax-h3",
|
||
"prompt": prompt,
|
||
"aspectRatio": aspect_ratio,
|
||
"images": image_inputs,
|
||
"audios": audio_inputs,
|
||
"resolution": resolution,
|
||
"duration": duration,
|
||
"replyType": "async",
|
||
}
|
||
if seed is not None:
|
||
payload["seed"] = seed
|
||
|
||
task_id = "未创建"
|
||
print(
|
||
"🚀 提交 MiniMax H3 异步视频生成 | "
|
||
f"比例: {aspect_ratio} | 分辨率: {resolution} | 时长: {duration} 秒 | "
|
||
f"参考图: {len(image_inputs)} 张 | 参考音频: {len(audio_inputs)} 段"
|
||
)
|
||
|
||
try:
|
||
response = self._make_request(
|
||
"POST",
|
||
"/v1/api/generate",
|
||
data=payload,
|
||
)
|
||
task_id = str(response.get("id", "未返回"))
|
||
if task_id and task_id != "未返回":
|
||
self.last_task_id = task_id
|
||
print(
|
||
f"📨 视频任务提交成功 | 任务: {task_id} | "
|
||
f"状态: {response.get('status', 'unknown')}"
|
||
)
|
||
|
||
completed = self._wait_for_async_result(response)
|
||
video_urls = self._extract_result_urls(completed)
|
||
print(
|
||
f"✅ 视频生成成功 | 模型: minimax-h3 | 任务: {task_id} | "
|
||
f"结果: {len(video_urls)} 个"
|
||
)
|
||
for index, url in enumerate(video_urls, 1):
|
||
print(f" 视频 {index}: {url}")
|
||
return video_urls
|
||
except Exception as exc:
|
||
print(
|
||
f"❌ 视频生成失败 | 模型: minimax-h3 | 任务: {task_id} | "
|
||
f"错误详情: {exc}"
|
||
)
|
||
if isinstance(exc, GrsaiAPIError):
|
||
raise
|
||
raise GrsaiAPIError(format_error_message(exc, "MiniMax H3 视频生成"))
|
||
|
||
def flux_generate_image(
|
||
self,
|
||
prompt: str,
|
||
model: str = "flux-kontext-pro",
|
||
seed: Optional[int] = None,
|
||
aspect_ratio: Optional[str] = None,
|
||
urls: Optional[List[str]] = None,
|
||
output_format: Optional[str] = None,
|
||
safety_tolerance: Optional[int] = None,
|
||
prompt_upsampling: Optional[bool] = None,
|
||
guidance_scale: Optional[float] = None,
|
||
num_inference_steps: Optional[int] = None,
|
||
) -> Tuple["Image.Image", str]:
|
||
# 构建请求数据
|
||
payload = {
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"images": urls or [],
|
||
}
|
||
|
||
# 动态添加所有非空的可选参数
|
||
# 这种方式更简洁且易于维护
|
||
optional_params = {
|
||
"seed": seed,
|
||
"aspectRatio": aspect_ratio,
|
||
"output_format": output_format,
|
||
"safetyTolerance": safety_tolerance,
|
||
"promptUpsampling": prompt_upsampling,
|
||
"guidance": guidance_scale,
|
||
"steps": num_inference_steps,
|
||
}
|
||
|
||
for key, value in optional_params.items():
|
||
# 只有当值不是None,或者对于字符串,不是空字符串时,才添加到payload
|
||
if value is not None and value != "":
|
||
payload[key] = value
|
||
|
||
try:
|
||
pil_images, image_urls, errors = self._generate_async(payload)
|
||
except Exception as e:
|
||
if isinstance(e, GrsaiAPIError):
|
||
raise
|
||
raise GrsaiAPIError(format_error_message(e, "图像生成"))
|
||
if not pil_images:
|
||
detail = "; ".join(errors) if errors else "没有可用结果"
|
||
raise GrsaiAPIError(f"图像生成成功,但图片下载失败: {detail}")
|
||
return pil_images[0], image_urls[0]
|
||
|
||
def test_connection(self) -> bool:
|
||
"""
|
||
测试API连接
|
||
|
||
Returns:
|
||
bool: 连接是否成功
|
||
"""
|
||
try:
|
||
# 尝试一个简单的请求来测试连接
|
||
self.flux_generate_image("test", seed=1)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
def get_api_status(self) -> Dict[str, Any]:
|
||
"""
|
||
获取API状态信息
|
||
|
||
Returns:
|
||
Dict: 状态信息
|
||
"""
|
||
status = {
|
||
"api_key_valid": bool(self.config.get_api_key()),
|
||
"base_url": self.config.get_config("api_base_url"),
|
||
"model": self.config.get_config("model"),
|
||
"timeout": self.config.get_config("timeout"),
|
||
}
|
||
|
||
# 测试连接
|
||
try:
|
||
status["connection_ok"] = self.test_connection()
|
||
except:
|
||
status["connection_ok"] = False
|
||
|
||
return status
|