Files
comfyui_o1key/clients/minimax_h3_client.py
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

243 lines
8.3 KiB
Python
Raw 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.
"""MiniMax H3 video client for the New API gateway."""
import json
from typing import Any, Callable, Dict, Optional
from urllib.parse import quote
import aiohttp
from ..utils.config import get_api_key_or_raise
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
POLL_DEADLINE_SECONDS as VIDEO_POLL_DEADLINE_SECONDS,
PollDeadline,
check_interrupt,
download_video_to_file,
extract_error_message,
extract_progress,
interruptible_sleep,
run_with_interrupt,
)
PENDING_STATUSES = {
"NOT_START",
"SUBMITTED",
"QUEUED",
"IN_PROGRESS",
"RUNNING",
"UNKNOWN",
}
SUCCESS_STATUSES = {"SUCCESS", "COMPLETED", "SUCCEEDED"}
FAILURE_STATUSES = {"FAILURE", "FAILED", "CANCELLED", "CANCELED"}
def extract_public_task_id(payload: Dict[str, Any]) -> str:
"""Return the New API public task ID, preferring ``id`` as documented."""
task_id = payload.get("id") or payload.get("task_id")
if not task_id:
raise RuntimeError("MiniMax H3 创建成功但未返回任务 ID。")
return str(task_id)
def parse_task_snapshot(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize New API's wrapper and MiniMax's official V2 task shape."""
if not isinstance(payload, dict):
raise RuntimeError("MiniMax H3 查询响应不是 JSON 对象。")
raw_data = payload.get("data")
data = raw_data if isinstance(raw_data, dict) else payload
raw_task = payload.get("task")
task = raw_task if isinstance(raw_task, dict) else {}
task_content = task.get("content") if isinstance(task.get("content"), dict) else {}
task_error = task.get("error") if isinstance(task.get("error"), dict) else {}
data_error = data.get("error") if isinstance(data.get("error"), dict) else {}
root_error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
status = str(
data.get("status") or task.get("status") or payload.get("status") or ""
).strip().upper()
error_message = str(
task_error.get("message")
or data_error.get("message")
or root_error.get("message")
or ""
).strip()
error_code = str(
task_error.get("code")
or data_error.get("code")
or root_error.get("code")
or ""
).strip()
if error_message and error_code:
error_message = f"{error_message}(错误码 {error_code}"
elif error_code:
error_message = f"错误码 {error_code}"
return {
"status": status,
"progress": extract_progress(payload),
"result_url": str(
data.get("result_url")
or task_content.get("url")
or metadata.get("url")
or data.get("url")
or payload.get("result_url")
or payload.get("url")
or ""
).strip(),
"fail_reason": str(
data.get("fail_reason")
or error_message
or extract_error_message(payload, "视频生成失败")
).strip(),
}
class MiniMaxH3Client:
"""Create, poll, and immediately download a MiniMax-H3 video task."""
CREATE_ENDPOINT = "/v1/video/generations"
STATUS_ENDPOINT = "/v1/videos/{task_id}"
POLL_INTERVAL_SECONDS = 10.0
POLL_DEADLINE_SECONDS = VIDEO_POLL_DEADLINE_SECONDS
def __init__(self, base_url: str, api_key: Optional[str] = None):
self.base_url = (base_url or "").rstrip("/")
if not self.base_url:
raise ValueError("MiniMax H3 New API Base URL 不能为空。")
self.api_key = api_key or get_api_key_or_raise()
def _headers(self) -> Dict[str, str]:
# Model API requests use the application API token. New-Api-User is
# intentionally not sent because it belongs to management API auth.
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
@staticmethod
async def _read_json(response: aiohttp.ClientResponse, action: str) -> Dict[str, Any]:
raw = await response.text()
try:
payload = json.loads(raw)
except json.JSONDecodeError:
raise RuntimeError(f"MiniMax H3 {action}返回了无效 JSON。") from None
if not isinstance(payload, dict):
raise RuntimeError(f"MiniMax H3 {action}响应不是 JSON 对象。")
return payload
async def submit_async(
self,
body: Dict[str, Any],
session: aiohttp.ClientSession,
) -> str:
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
check_interrupt()
response = await run_with_interrupt(
async_request_with_retry(
session,
"POST",
url,
json=body,
headers=self._headers(),
prefix="MiniMax H3 创建任务:",
)
)
payload = await self._read_json(response, "创建任务")
return extract_public_task_id(payload)
async def poll_async(
self,
task_id: str,
session: aiohttp.ClientSession,
on_progress: Optional[Callable[[int], None]] = None,
) -> str:
encoded_task_id = quote(task_id, safe="")
url = f"{self.base_url}{self.STATUS_ENDPOINT.format(task_id=encoded_task_id)}"
deadline = PollDeadline(
seconds=self.POLL_DEADLINE_SECONDS,
label=f"MiniMax H3(任务 {task_id}",
)
while True:
deadline.check()
check_interrupt()
response = await run_with_interrupt(
async_request_with_retry(
session,
"GET",
url,
headers=self._headers(),
prefix="MiniMax H3 查询任务:",
)
)
payload = await self._read_json(response, "查询任务")
snapshot = parse_task_snapshot(payload)
status = snapshot["status"]
progress = snapshot["progress"]
# A successful terminal state is authoritative even if an older
# gateway omits data.progress or returns a stale percentage.
if status in SUCCESS_STATUSES:
progress = 100
print(f"[MiniMax H3] 任务 {task_id}{status or 'UNKNOWN'} {progress}%")
if on_progress:
on_progress(progress)
if status in SUCCESS_STATUSES:
result_url = snapshot["result_url"]
if not result_url:
raise RuntimeError(
f"MiniMax H3 任务 {task_id} 已成功,但响应缺少 data.result_url。"
)
return result_url
if status in FAILURE_STATUSES:
raise RuntimeError(
f"MiniMax H3 任务 {task_id} 生成失败:{snapshot['fail_reason']}"
)
if status not in PENDING_STATUSES:
raise RuntimeError(
f"MiniMax H3 任务 {task_id} 返回不支持的状态 {status or '<空>'}。"
)
await interruptible_sleep(self.POLL_INTERVAL_SECONDS)
async def generate_async(
self,
body: Dict[str, Any],
save_path: str,
on_stage: Optional[Callable[[str], None]] = None,
on_progress: Optional[Callable[[int], None]] = None,
) -> tuple[str, str]:
connector = aiohttp.TCPConnector(force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
if on_stage:
on_stage("submitting")
task_id = await self.submit_async(body, session)
print(f"[MiniMax H3] 已提交公开任务 ID{task_id}")
if on_stage:
on_stage(f"submitted:{task_id}")
result_url = await self.poll_async(
task_id,
session,
on_progress=on_progress,
)
if on_stage:
on_stage("downloading")
await download_video_to_file(
session,
result_url,
save_path,
label=f"MiniMax H3 {task_id}",
)
if on_stage:
on_stage("done")
return save_path, task_id