feat: 新增 Grok 图像节点、前端 UI 增强、重构 nano-banana 系列

- 新增 Grok Image 节点及客户端
- 新增 save_image_format 节点
- 新增前端 JS 扩展:画笔工具、点阵网格、侧边栏隐藏、资源切换、重命名等
- 重构 nano-banana 节点,移除 pro 版本
- 移除 multi_res_preview 节点
- 新增 http_error 工具模块
- 各客户端和节点优化改进

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Jony
2026-05-24 22:46:55 +08:00
co-authored by Claude Opus 4.6
parent c491731c99
commit 69279c654d
40 changed files with 3406 additions and 1599 deletions
+53 -40
View File
@@ -14,6 +14,8 @@ import time
import aiohttp
from ..utils.http_error import HTTP_ERROR_MESSAGES, RETRYABLE_STATUS_CODES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR
class BaseAPIClient(ABC):
@@ -206,7 +208,8 @@ class BaseAPIClient(ABC):
if response.status != 200:
error_text = await response.text()
raise RuntimeError(error_text)
# 返回状态码和错误文本,由外层处理重试
return {"_error": True, "_status": response.status, "_text": error_text}
wait_start = time.time()
response_data = await response.json()
@@ -231,31 +234,55 @@ class BaseAPIClient(ABC):
return
try:
if _interrupt_available:
request_task = asyncio.ensure_future(_do_request())
interrupt_task = asyncio.ensure_future(_poll_interrupt())
last_error_status = None
last_error_text = ""
done, pending = await asyncio.wait(
[request_task, interrupt_task],
return_when=asyncio.FIRST_COMPLETED
)
for attempt in range(DEFAULT_MAX_RETRIES + 1):
if _interrupt_available:
request_task = asyncio.ensure_future(_do_request())
interrupt_task = asyncio.ensure_future(_poll_interrupt())
# 取消未完成的任务
for t in pending:
t.cancel()
try:
await t
except (asyncio.CancelledError, Exception):
pass
done, pending = await asyncio.wait(
[request_task, interrupt_task],
return_when=asyncio.FIRST_COMPLETED
)
# 判断是哪个先完成
if interrupt_task in done and request_task not in done:
raise InterruptProcessingException()
for t in pending:
t.cancel()
try:
await t
except (asyncio.CancelledError, Exception):
pass
# 请求完成,取出结果(可能含异常)
return request_task.result()
else:
return await _do_request()
if interrupt_task in done and request_task not in done:
raise InterruptProcessingException()
result = request_task.result()
else:
result = await _do_request()
if isinstance(result, dict) and result.get("_error"):
status = result["_status"]
error_text = result["_text"]
last_error_status = status
last_error_text = error_text
if status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
friendly = HTTP_ERROR_MESSAGES.get(status, f"请求失败 ({status})")
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
print(f"{friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
await asyncio.sleep(delay)
continue
if status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[status])
raise RuntimeError(error_text)
return result
if last_error_status and last_error_status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[last_error_status])
raise RuntimeError(last_error_text)
except InterruptProcessingException:
raise
@@ -352,30 +379,16 @@ class BaseAPIClient(ABC):
custom = self.get_http_error_message(429, error_message)
if custom is not None:
raise RuntimeError(custom)
raise RuntimeError(
f"请求频率超限 (429 Too Many Requests)\n"
f"API 返回错误:{error_message}\n"
f"建议:等待一段时间后重试"
)
raise RuntimeError(HTTP_ERROR_MESSAGES[429])
elif response.status == 503:
custom = self.get_http_error_message(503, error_message)
if custom is not None:
raise RuntimeError(custom)
raise RuntimeError(
f"服务暂时不可用 (503 Service Unavailable)\n"
f"API 返回错误:{error_message}\n"
f"建议:稍后重试"
)
raise RuntimeError(HTTP_ERROR_MESSAGES[503])
elif response.status == 504:
raise RuntimeError(
f"API 请求超时 (504 Gateway Timeout)\n"
f"API 返回错误:{error_message}\n"
f"建议:稍后重试"
)
raise RuntimeError(HTTP_ERROR_MESSAGES[504])
elif response.status == 502:
raise RuntimeError(
"糟糕!请求到上游时遇到超时或过载!别担心,过会儿再次点击运行即可!"
)
raise RuntimeError(HTTP_ERROR_MESSAGES[502])
else:
raise RuntimeError(
f"API 请求失败 (状态码: {response.status})\n"
+46 -29
View File
@@ -21,6 +21,7 @@ from PIL import Image
from ..utils.config import get_api_key_or_raise, get_api_base_url
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import RETRYABLE_STATUS_CODES, HTTP_ERROR_MESSAGES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR
# ── 固定端点 ──────────────────────────────────────────────────────────────────
@@ -245,39 +246,55 @@ class DoubaoImageClient:
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
# 3. 发送 POST 请求
t0 = time.time()
async with session.post(
url,
json=body,
headers=self._headers(),
) as resp:
elapsed_req = time.time() - t0
text = await resp.text()
# 3. 发送 POST 请求(带退避重试)
last_status = None
for attempt in range(DEFAULT_MAX_RETRIES + 1):
t0 = time.time()
async with session.post(
url,
json=body,
headers=self._headers(),
) as resp:
elapsed_req = time.time() - t0
text = await resp.text()
if resp.status != 200:
last_status = resp.status
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
friendly = HTTP_ERROR_MESSAGES.get(resp.status)
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
print(f"[豆包生图] {friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
await asyncio.sleep(delay)
continue
if resp.status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
if isinstance(err_obj, dict):
msg = (
err_obj.get("message")
or err_obj.get("msg")
or text
)
else:
msg = str(err_obj) or text
except Exception:
msg = text
raise RuntimeError(
f"请求失败 HTTP {resp.status}: {msg}"
)
if resp.status != 200:
# 尝试解析错误信息
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
if isinstance(err_obj, dict):
msg = (
err_obj.get("message")
or err_obj.get("msg")
or text
)
else:
msg = str(err_obj) or text
resp_json = json.loads(text)
except Exception:
msg = text
raise RuntimeError(
f"请求失败 HTTP {resp.status}: {msg}"
)
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
try:
resp_json = json.loads(text)
except Exception:
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
break
else:
if last_status and last_status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[last_status])
raise RuntimeError(f"请求失败: 重试 {DEFAULT_MAX_RETRIES} 次后仍然失败")
print(f"[豆包生图] API 响应耗时 {elapsed_req:.1f}s,开始下载图像...")
+3
View File
@@ -14,6 +14,7 @@ from typing import Optional
import requests
from ..utils.config import get_api_key_or_raise, get_api_base_url
from ..utils.http_error import HTTP_ERROR_MESSAGES
# 显示名 → 实际请求值的映射
@@ -112,6 +113,8 @@ class FluxEditClient:
raise RuntimeError("无法连接到服务器,请检查网络或服务器地址")
if resp.status_code != 200:
if resp.status_code in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status_code])
raise RuntimeError(
f"提交任务失败 (HTTP {resp.status_code})\n"
f"响应: {resp.text[:500]}"
+1 -1
View File
@@ -44,7 +44,7 @@ class GeminiAsyncImageProvider(BaseAsyncImageProvider):
@property
def api_base_url(self) -> str:
return get_async_api_base_url()
return getattr(self, '_route_base_url', None) or get_async_api_base_url()
def get_submit_endpoint(self, model: str, resolution: str) -> str:
gemini_endpoint = self._client.get_endpoint(
+23 -8
View File
@@ -187,6 +187,7 @@ class GeminiAPIClient(BaseAPIClient):
enable_grounding: bool = False,
enable_image_search: bool = False,
image_compression: str = None,
thinking_level: str = None,
**kwargs
) -> Dict[str, Any]:
"""
@@ -227,15 +228,15 @@ class GeminiAPIClient(BaseAPIClient):
})
# 估算完整 body 大小(不含工具字段,工具字段很小可忽略)
est_image_config = {"imageSize": resolution}
if aspect_ratio and aspect_ratio != "智能":
est_image_config["aspectRatio"] = aspect_ratio
estimated = self._estimate_body_size(
parts + img_parts,
{
"generationConfig": {
"responseModalities": ["IMAGE"],
"imageConfig": {
"aspectRatio": aspect_ratio,
"imageSize": resolution
}
"imageConfig": est_image_config
}
}
)
@@ -267,6 +268,10 @@ class GeminiAPIClient(BaseAPIClient):
parts.extend(img_parts)
# 构建请求体
image_config = {"imageSize": resolution}
if aspect_ratio and aspect_ratio != "智能":
image_config["aspectRatio"] = aspect_ratio
request_body = {
"contents": [
{
@@ -276,13 +281,17 @@ class GeminiAPIClient(BaseAPIClient):
],
"generationConfig": {
"responseModalities": ["IMAGE"],
"imageConfig": {
"aspectRatio": aspect_ratio,
"imageSize": resolution
}
"imageConfig": image_config
}
}
# 添加思考深度配置
if thinking_level:
request_body["generationConfig"]["thinkingConfig"] = {
"thinkingLevel": thinking_level,
"includeThoughts": True
}
# 添加图片压缩参数
if image_compression:
request_body["image_compression"] = image_compression
@@ -564,6 +573,7 @@ class GeminiAPIClient(BaseAPIClient):
enable_grounding: bool = False,
enable_image_search: bool = False,
image_format: str = "base64",
thinking_level: str = None,
) -> tuple[List[Image.Image], Dict[str, Any]]:
"""
单次异步生成请求(极简单行日志)
@@ -602,6 +612,7 @@ class GeminiAPIClient(BaseAPIClient):
resolution=resolution,
enable_grounding=enable_grounding,
enable_image_search=enable_image_search,
thinking_level=thinking_level,
)
build_time = time.time() - build_start
@@ -737,6 +748,7 @@ class GeminiAPIClient(BaseAPIClient):
enable_grounding: bool = False,
enable_image_search: bool = False,
image_format: str = "base64",
thinking_level: str = None,
) -> List[Image.Image]:
"""
批量全并发生成 - 改进版:支持分批处理和内存管理
@@ -801,6 +813,7 @@ class GeminiAPIClient(BaseAPIClient):
enable_grounding=enable_grounding,
enable_image_search=enable_image_search,
image_format=image_format,
thinking_level=thinking_level,
),
name=f"task_{task_index}"
)
@@ -873,6 +886,7 @@ class GeminiAPIClient(BaseAPIClient):
enable_grounding: bool = False,
enable_image_search: bool = False,
image_format: str = "base64",
thinking_level: str = None,
) -> List[Image.Image]:
"""
同步生成接口(用于 ComfyUI)
@@ -906,6 +920,7 @@ class GeminiAPIClient(BaseAPIClient):
enable_grounding=enable_grounding,
enable_image_search=enable_image_search,
image_format=image_format,
thinking_level=thinking_level,
)
return self.run_async_in_thread(coro)
+38 -20
View File
@@ -26,6 +26,7 @@ from PIL import Image
from ..utils.config import get_api_key_or_raise, get_api_base_url
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import RETRYABLE_STATUS_CODES, HTTP_ERROR_MESSAGES, _compute_delay, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
@@ -330,31 +331,46 @@ class GptImageClient:
async def _do_request():
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
t0 = time.time()
async with session.post(url, json=body, headers=self._json_headers()) as resp:
elapsed = time.time() - t0
text = await resp.text()
last_status = None
for attempt in range(DEFAULT_MAX_RETRIES + 1):
t0 = time.time()
async with session.post(url, json=body, headers=self._json_headers()) as resp:
elapsed = time.time() - t0
text = await resp.text()
if resp.status != 200:
last_status = resp.status
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
friendly = HTTP_ERROR_MESSAGES.get(resp.status)
delay = _compute_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_BACKOFF_FACTOR)
print(f"[o1key GPT Image] {friendly} {delay:.1f}s 后重试 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
await asyncio.sleep(delay)
continue
if resp.status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
msg = (
err_obj.get("message") or err_obj.get("msg") or text
if isinstance(err_obj, dict)
else str(err_obj) or text
)
except Exception:
msg = text
raise RuntimeError(f"请求失败 HTTP {resp.status}: {msg}")
if resp.status != 200:
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
msg = (
err_obj.get("message") or err_obj.get("msg") or text
if isinstance(err_obj, dict)
else str(err_obj) or text
)
resp_json = json.loads(text)
except Exception:
msg = text
raise RuntimeError(f"请求失败 HTTP {resp.status}: {msg}")
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
try:
resp_json = json.loads(text)
except Exception:
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
return await self._parse_response(resp_json, session)
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
return await self._parse_response(resp_json, session)
if last_status and last_status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[last_status])
raise RuntimeError(f"请求失败: 重试 {DEFAULT_MAX_RETRIES} 次后仍然失败")
return await self._run_with_interrupt(_do_request())
@@ -446,6 +462,8 @@ class GptImageClient:
text = await resp.text()
if resp.status != 200:
if resp.status in HTTP_ERROR_MESSAGES:
raise RuntimeError(HTTP_ERROR_MESSAGES[resp.status])
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
+355
View File
@@ -0,0 +1,355 @@
"""
Grok Image API 客户端
支持两个接口:
- POST /v1/images/generations 文生图
- POST /v1/images/edits 图生图(带参考图)
上游 API 格式与 OpenAI Images API 兼容。
"""
import asyncio
import base64
import json
import time
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from typing import List, Optional
import aiohttp
import numpy as np
import torch
from PIL import Image
from ..utils.config import get_api_key_or_raise, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
_INTERRUPT_AVAILABLE = True
except ImportError:
_INTERRUPT_AVAILABLE = False
InterruptProcessingException = RuntimeError
processing_interrupted = lambda: False
_ENDPOINT_GENERATIONS = "/v1/images/generations"
_ENDPOINT_EDITS = "/v1/images/edits"
_MODEL_NAME_MAP = {
"Grok Image": "grok-imagine-image",
"Grok Image Pro": "grok-imagine-image-quality",
}
_REQUEST_TIMEOUT = 900
_MAX_BODY_BYTES = 20 * 1024 * 1024
_MAX_RETRIES = 3
_RETRY_DELAY = 5
class GrokImageClient:
def __init__(self, route: str = "全球加速"):
self.api_key = get_api_key_or_raise("O1KEY_API_KEY")
self.base_url = get_base_url_by_route(route)
def _json_headers(self) -> dict:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
def _auth_headers(self) -> dict:
return {"Authorization": f"Bearer {self.api_key}"}
# ── 图像工具 ──────────────────────────────────────────────────────────────
@staticmethod
def _shrink_png_to_limit(png_bytes: bytes, max_bytes: int, label: str = "") -> bytes:
if len(png_bytes) <= max_bytes:
return png_bytes
img = Image.open(BytesIO(png_bytes))
w, h = img.size
original_size = len(png_bytes)
step = 0
while len(png_bytes) > max_bytes:
scale = 0.894
w = max(1, int(w * scale))
h = max(1, int(h * scale))
img = img.resize((w, h), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG")
png_bytes = buf.getvalue()
step += 1
tag = f" ({label})" if label else ""
print(
f"[o1key Grok Image] 图像{tag}超出 {max_bytes // (1024*1024)}MB 限制,"
f"已等比缩放 {step} 次:{original_size // 1024}KB → {len(png_bytes) // 1024}KB "
f"{w}×{h}"
)
return png_bytes
@staticmethod
def _pil_list_to_tensor(images: List[Image.Image]) -> torch.Tensor:
if not images:
placeholder = Image.new("RGB", (512, 512), (128, 128, 128))
images = [placeholder]
tensors = []
for img in images:
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
tensors.append(torch.from_numpy(arr))
return torch.stack(tensors, dim=0)
# ── 中断轮询 ──────────────────────────────────────────────────────────────
@staticmethod
async def _poll_interrupt():
while True:
await asyncio.sleep(0.5)
if _INTERRUPT_AVAILABLE and processing_interrupted():
return
@staticmethod
async def _run_with_interrupt(coro):
if not _INTERRUPT_AVAILABLE:
return await coro
request_task = asyncio.ensure_future(coro)
interrupt_task = asyncio.ensure_future(GrokImageClient._poll_interrupt())
done, pending = await asyncio.wait(
[request_task, interrupt_task],
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
try:
await t
except (asyncio.CancelledError, Exception):
pass
if interrupt_task in done and request_task not in done:
raise InterruptProcessingException()
return request_task.result()
# ── 响应解析 ──────────────────────────────────────────────────────────────
async def _parse_response(self, resp_json: dict, session: aiohttp.ClientSession) -> List[Image.Image]:
if "error" in resp_json:
err = resp_json["error"]
msg = (
err.get("message") or err.get("msg") or json.dumps(err, ensure_ascii=False)
if isinstance(err, dict) else str(err)
)
raise RuntimeError(f"API 返回错误: {msg}")
data_list = resp_json.get("data")
if not data_list:
raise RuntimeError(f"API 响应中未找到 data 字段")
images: List[Image.Image] = []
for idx, item in enumerate(data_list):
b64 = item.get("b64_json", "")
url = item.get("url", "")
if b64:
img_bytes = base64.b64decode(b64)
img = Image.open(BytesIO(img_bytes))
images.append(img)
elif url and url.startswith("http"):
async with session.get(url, allow_redirects=True) as r:
if r.status != 200:
raise RuntimeError(f"图像下载失败 HTTP {r.status}")
img_bytes = await r.read()
images.append(Image.open(BytesIO(img_bytes)))
else:
print(f"[o1key Grok Image] 警告:第 {idx + 1} 条数据无有效图像,已跳过")
return images
# ── 文生图(generations 接口)─────────────────────────────────────────────
async def _generate_async(
self,
prompt: str,
model: str,
aspect_ratio: str,
resolution: str,
n: int,
) -> List[Image.Image]:
api_model = _MODEL_NAME_MAP.get(model, model)
body: dict = {
"model": api_model,
"prompt": prompt,
"aspect_ratio": aspect_ratio if aspect_ratio else "auto",
"resolution": resolution if resolution else "1k",
"response_format": "b64_json",
}
url = f"{self.base_url}{_ENDPOINT_GENERATIONS}"
log_body = {k: v for k, v in body.items()}
print(f"[o1key Grok Image] 请求 URL: {url}")
print(f"[o1key Grok Image] 请求体: {json.dumps(log_body, ensure_ascii=False)}")
results = []
for i in range(n):
images = await self._do_request_with_retry(url, body)
results.extend(images)
if n > 1:
print(f"[o1key Grok Image] 第 {i+1}/{n} 张完成")
return results
# ── 图生图(edits 接口)───────────────────────────────────────────────────
async def _edit_async(
self,
prompt: str,
model: str,
aspect_ratio: str,
resolution: str,
n: int,
image_list: List[torch.Tensor],
) -> List[Image.Image]:
api_model = _MODEL_NAME_MAP.get(model, model)
body: dict = {
"model": api_model,
"prompt": prompt,
"response_format": "b64_json",
}
if aspect_ratio and aspect_ratio != "auto":
body["aspect_ratio"] = aspect_ratio
if resolution:
body["resolution"] = resolution
# 参考图转 base64 字符串
pil_images = tensor_to_pil(image_list[0])
img = pil_images[0]
buf = BytesIO()
img.save(buf, format="PNG")
png_bytes = buf.getvalue()
png_bytes = self._shrink_png_to_limit(png_bytes, _MAX_BODY_BYTES // 2)
body["image"] = base64.b64encode(png_bytes).decode("utf-8")
url = f"{self.base_url}{_ENDPOINT_EDITS}"
log_body = {k: (v[:50] + "..." if k == "image" and len(v) > 50 else v) for k, v in body.items()}
print(f"[o1key Grok Image] 请求 URL: {url}")
print(f"[o1key Grok Image] 请求体: {json.dumps(log_body, ensure_ascii=False)}")
results = []
for i in range(n):
images = await self._do_request_with_retry(url, body)
results.extend(images)
if n > 1:
print(f"[o1key Grok Image] 第 {i+1}/{n} 张完成")
return results
# ── 带重试的请求 ────────────────────────────────────────────────────────
async def _do_request_with_retry(self, url: str, body: dict) -> List[Image.Image]:
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async def _do_request():
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
last_error = None
for attempt in range(1, _MAX_RETRIES + 1):
t0 = time.time()
async with session.post(url, json=body, headers=self._json_headers()) as resp:
elapsed = time.time() - t0
text = await resp.text()
if resp.status == 429 or resp.status in (502, 503, 504):
last_error = f"HTTP {resp.status}"
print(f"[o1key Grok Image] 重试 {attempt}/{_MAX_RETRIES}{last_error}")
await asyncio.sleep(_RETRY_DELAY * attempt)
continue
if resp.status == 400 and "high load" in text.lower():
last_error = "high load"
print(f"[o1key Grok Image] 重试 {attempt}/{_MAX_RETRIES}(服务繁忙)")
await asyncio.sleep(_RETRY_DELAY * attempt)
continue
if resp.status != 200:
try:
err_json = json.loads(text)
err_obj = err_json.get("error", {})
msg = (
err_obj.get("message") or err_obj.get("msg") or text
if isinstance(err_obj, dict) else str(err_obj) or text
)
except Exception:
msg = text
raise RuntimeError(f"请求失败 HTTP {resp.status}: {msg}")
try:
resp_json = json.loads(text)
except Exception:
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
print(f"[o1key Grok Image] API 响应耗时 {elapsed:.1f}s")
return await self._parse_response(resp_json, session)
raise RuntimeError(f"重试 {_MAX_RETRIES} 次后仍失败: {last_error}")
return await self._run_with_interrupt(_do_request())
# ── 同步入口 ──────────────────────────────────────────────────────────────
def run_sync(
self,
prompt: str,
model: str,
aspect_ratio: str,
resolution: str,
n: int,
image_list: Optional[List[torch.Tensor]] = None,
) -> List[Image.Image]:
if image_list:
coro = self._edit_async(
prompt=prompt, model=model, aspect_ratio=aspect_ratio,
resolution=resolution, n=n, image_list=image_list,
)
else:
coro = self._generate_async(
prompt=prompt, model=model, aspect_ratio=aspect_ratio,
resolution=resolution, n=n,
)
def _run():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_run)
try:
return future.result(timeout=_REQUEST_TIMEOUT + 30)
except TimeoutError:
raise RuntimeError("Grok Image 请求超时,请检查网络或稍后重试")
# ── 余额查询 ──────────────────────────────────────────────────────────────
async def _query_balance_async(self) -> dict:
url = f"{self.base_url}/api/usage/token"
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
async with session.get(url, headers=self._auth_headers()) as resp:
if resp.status != 200:
raise RuntimeError(f"余额查询失败 HTTP {resp.status}")
return await resp.json()
def query_balance_sync(self) -> dict:
def _run():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(self._query_balance_async())
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(_run).result(timeout=15)
@staticmethod
def format_balance_info(balance_data: dict) -> str:
data = balance_data.get("data", {})
api_name = data.get("name", "未知")
total_available = data.get("total_available", 0)
balance_in_dollars = total_available / 500000
return f"当前余额:{balance_in_dollars:.2f} | API{api_name}"
+11 -16
View File
@@ -10,6 +10,7 @@ from typing import Any, Callable, Dict, Optional
import aiohttp
from ..utils.config import get_api_key_or_raise, get_api_base_url
from ..utils.http_error import async_request_with_retry
class KlingClient:
@@ -49,11 +50,11 @@ class KlingClient:
) -> Dict[str, Any]:
url = f"{self.base_url}{self.ENDPOINTS[endpoint_type]}"
async with session.post(url, json=body, headers=self._headers()) as resp:
text = await resp.text()
if resp.status != 200:
raise RuntimeError(f"提交失败 ({resp.status}): {text}")
return json.loads(text)
resp = await async_request_with_retry(
session, "POST", url, json=body, headers=self._headers(), prefix="Kling 提交: "
)
text = await resp.text()
return json.loads(text)
# ── 轮询状态 ──────────────────────────────────────────────────────
@@ -203,17 +204,11 @@ class KlingClient:
if on_stage:
on_stage("submitting")
create_url = f"{self.base_url}{self.NEW_API_CREATE}"
async with session.post(create_url, json=body, headers=headers) as resp:
text = await resp.text()
if resp.status != 200:
# 尝试提取友好错误信息
try:
err = json.loads(text)
msg = err.get("error", {}).get("message") or err.get("message") or text
except Exception:
msg = text
raise RuntimeError(f"动作控制提交失败 ({resp.status}): {msg}")
create_resp = json.loads(text)
resp = await async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="Kling 动作控制提交: "
)
text = await resp.text()
create_resp = json.loads(text)
video_id = create_resp.get("id")
if not video_id:
+3 -1
View File
@@ -204,13 +204,15 @@ class OpenAIAPIClient(BaseAPIClient):
"extra_body": {
"google": {
"image_config": {
"aspect_ratio": aspect_ratio,
"image_size": api_image_size
}
}
}
}
if aspect_ratio and aspect_ratio != "智能":
request_body["extra_body"]["google"]["image_config"]["aspect_ratio"] = aspect_ratio
return request_body
def parse_response(self, response: Dict[str, Any]) -> List[Image.Image]:
+6 -12
View File
@@ -11,6 +11,7 @@ from typing import Any, Callable, Dict, Optional
import aiohttp
from ..utils.config import get_api_key_or_raise
from ..utils.http_error import async_request_with_retry
class SeedanceClient:
@@ -47,18 +48,11 @@ class SeedanceClient:
) -> str:
"""提交视频生成任务,返回 task_id"""
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
async with session.post(url, json=body, headers=self._headers()) as resp:
text = await resp.text()
if resp.status != 200:
try:
err = json.loads(text)
msg = (err.get("error", {}).get("message")
or err.get("message")
or text)
except Exception:
msg = text
raise RuntimeError(f"提交失败 ({resp.status}): {msg}")
data = json.loads(text)
resp = await async_request_with_retry(
session, "POST", url, json=body, headers=self._headers(), prefix="Seedance 提交: "
)
text = await resp.text()
data = json.loads(text)
# new-api 返回字段:id / task_id
task_id = data.get("id") or data.get("task_id")