Files
comfyui_o1key/utils/nano_banana_async.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

1820 lines
67 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.
import asyncio
import base64
import binascii
import json
import math
import os
import time
from io import BytesIO
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
from PIL import Image, UnidentifiedImageError
from .http2_client import (
HTTP_CLIENT_ERRORS,
HTTP_STREAM_ERRORS,
create_timeout,
format_response_body_diagnostics,
read_response_body_with_diagnostics,
response_task_id,
validate_response_task_id,
)
from .http_error import (
extract_error_detail,
extract_error_status_code,
extract_structured_error_message,
get_friendly_message,
)
from ..clients.gemini_client import GeminiAPIClient
_SUBMIT_ENDPOINT = "/async/v1/generateImage"
_TASK_ENDPOINT = "/async/v1/tasks/{task_id}"
_TEMP_UPLOAD_ENDPOINT = "/v1/o1key/uploads"
_POLL_SCHEDULE = [3.0, 6.0, 9.0]
_POLL_INTERVAL = 3.0
_MAX_WAIT_SECONDS = 900.0
_INTERRUPT_STEP = 0.2
_RUNNING_PROGRESS_MAX = 0.99
_POLL_TRANSPORT_RETRY_DELAYS = (1.0, 2.0, 4.0)
_INLINE_RESULT_RETRY_DELAYS = (1.0, 2.0, 4.0)
_POLL_REQUEST_TIMEOUT = create_timeout(
180.0, connect=30.0, read=180.0, write=30.0, pool=30.0
)
_DOWNLOAD_TIMEOUT = create_timeout(
120.0, connect=30.0, read=60.0, write=30.0, pool=30.0
)
_DOWNLOAD_RETRY_DELAYS = (1.0, 2.0)
_DOWNLOAD_CHUNK_SIZE = 256 * 1024
_UPLOAD_RETRY_DELAYS = (1.0, 2.0, 4.0)
_UPLOAD_TIMEOUT = create_timeout(
300.0, connect=30.0, read=300.0, write=300.0, pool=30.0
)
NANO_BANANA_REQUEST_BODY_LIMIT_BYTES = 18 * 1024 * 1024
NANO_BANANA_RESIZE_MODES = ("不缩放", "智能缩放")
_SMART_RESIZE_MIN_LONG_EDGE = 256
_SMART_RESIZE_MAX_ATTEMPTS = 32
_NETWORK_ERRORS = HTTP_CLIENT_ERRORS + (asyncio.TimeoutError,)
_POLL_RETRY_ERRORS = HTTP_CLIENT_ERRORS + (
asyncio.TimeoutError,
OSError,
UnicodeDecodeError,
json.JSONDecodeError,
)
_DOWNLOAD_ERRORS = HTTP_CLIENT_ERRORS + (
asyncio.TimeoutError,
OSError,
UnidentifiedImageError,
)
class _IncompleteInlineImageError(RuntimeError):
"""The task succeeded, but its inline image payload is incomplete."""
def _env_flag(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None or not str(raw).strip():
return default
return str(raw).strip().lower() not in ("0", "false", "no", "off")
# 精简诊断日志默认关闭;需要查看轮询状态时再显式开启。
# 开启:环境变量 O1KEY_DEBUG_LOG=1
DEBUG_LOG_ENABLED = _env_flag("O1KEY_DEBUG_LOG", False)
# 完整请求体、响应头和响应体默认关闭,排查原始报文时再临时打开。
# 开启:环境变量 O1KEY_VERBOSE_LOG=1
VERBOSE_LOG_ENABLED = _env_flag("O1KEY_VERBOSE_LOG", False)
# 请求/响应里的 base64 图片数据始终折叠,禁止写入日志。
# 默认不打印 API 响应体;排查接口时再显式开启。
# 开启:环境变量 O1KEY_RESPONSE_LOG=1。
# 响应中的 base64 始终折叠。
RESPONSE_LOG_ENABLED = _env_flag("O1KEY_RESPONSE_LOG", False)
_POLL_LOG_ENABLED = DEBUG_LOG_ENABLED
_SUCCESS_STATUSES = {"success", "succeed", "succeeded", "completed", "done", "finished"}
_FAILURE_STATUSES = {
"failure",
"fail",
"failed",
"error",
"expired",
"timeout",
"timed_out",
"cancel",
"canceled",
"cancelled",
"rejected",
}
_RUNNING_STATUSES = {
"submitted",
"queued",
"pending",
"running",
"processing",
"in_progress",
"in-progress",
"created",
}
def _normalize_image_format(fmt: Optional[str]) -> Optional[str]:
if not fmt:
return None
normalized = str(fmt).upper()
if normalized == "JPG":
return "JPEG"
return normalized
def _open_result_image(data: bytes) -> Image.Image:
img = Image.open(BytesIO(data))
fmt = _normalize_image_format(img.format)
img.load()
if fmt:
img.format = fmt
setattr(img, "_o1key_original_format", fmt)
setattr(img, "_o1key_original_bytes", data)
return img
def _decode_inline_result_image(value: str, label: str) -> Tuple[bytes, Image.Image]:
"""Strictly decode and fully load one inline result image."""
try:
if not isinstance(value, str) or not value:
raise ValueError("empty base64 payload")
image_bytes = base64.b64decode(value, validate=True)
if not image_bytes:
raise ValueError("decoded image is empty")
return image_bytes, _open_result_image(image_bytes)
except (binascii.Error, ValueError, OSError, UnidentifiedImageError) as exc:
raise _IncompleteInlineImageError(f"{label}不完整或无法解码: {exc}") from None
def _preserve_original_image_info(source: Image.Image, target: Image.Image) -> Image.Image:
fmt = _normalize_image_format(
getattr(source, "_o1key_original_format", None) or getattr(source, "format", None)
)
if fmt:
target.format = fmt
setattr(target, "_o1key_original_format", fmt)
original_bytes = getattr(source, "_o1key_original_bytes", None)
if original_bytes is not None:
setattr(target, "_o1key_original_bytes", original_bytes)
return target
def _headers(api_key: str) -> Dict[str, str]:
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
def _raw_response_headers(response: Any) -> str:
"""Return response headers in both aiohttp's raw and readable forms.
``raw_headers`` keeps duplicate headers and the original byte values, which
is useful when diagnosing proxy/upstream differences between concurrent
requests.
"""
raw_headers = getattr(response, "raw_headers", ()) or ()
raw_lines = [repr(tuple(raw_headers))]
readable_lines = []
for name, value in raw_headers:
if isinstance(name, bytes):
name = name.decode("latin-1", errors="replace")
if isinstance(value, bytes):
value = value.decode("latin-1", errors="replace")
readable_lines.append(f" {name}: {value}")
if not readable_lines:
readable_lines = [f" {key}: {value}" for key, value in response.headers.items()]
return "raw_headers=" + raw_lines[0] + "\n" + "\n".join(readable_lines)
def _extract_response_eagleid(response: Any) -> str:
"""Extract Eagleid case-insensitively, including common prefixed variants."""
eagleids = []
for name, value in getattr(response, "raw_headers", ()) or ():
if isinstance(name, bytes):
name = name.decode("latin-1", errors="replace")
normalized = str(name).lower().replace("-", "").replace("_", "")
if normalized == "eagleid" or normalized.endswith("eagleid"):
if isinstance(value, bytes):
value = value.decode("latin-1", errors="replace")
eagleids.append(str(value))
if eagleids:
return ",".join(eagleids)
for name, value in response.headers.items():
normalized = str(name).lower().replace("-", "").replace("_", "")
if normalized == "eagleid" or normalized.endswith("eagleid"):
eagleids.append(str(value))
return ",".join(eagleids)
def _log_response_headers(label: str, response: Any) -> str:
eagleid = _extract_response_eagleid(response)
eagleid_text = eagleid or "<none>"
protocol = getattr(response, "http_version", "") or "HTTP"
print(
f"[{label}] {protocol} {response.status} | Eagleid={eagleid_text}\n"
f"{_raw_response_headers(response)}"
)
return eagleid
def _append_eagleid(message: str, eagleid: str) -> str:
if not eagleid:
return message
return f"{message} (Eagleid={eagleid})"
def _json_dumps(body: Dict[str, Any]) -> str:
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
def _json_size(body: Dict[str, Any]) -> int:
return len(_json_dumps(body).encode("utf-8"))
def _png_bytes(image: Image.Image) -> bytes:
working = image
if working.mode not in ("RGB", "RGBA"):
working = working.convert("RGB")
buffered = BytesIO()
working.save(buffered, format="PNG")
return buffered.getvalue()
def _original_image_bytes(image: Image.Image, expected_format: str) -> Optional[bytes]:
source_format = _normalize_image_format(
getattr(image, "_o1key_original_format", None) or getattr(image, "format", None)
)
if source_format != expected_format:
return None
original_bytes = getattr(image, "_o1key_original_bytes", None)
if isinstance(original_bytes, bytes):
return original_bytes
original_path = getattr(image, "_o1key_original_path", None)
if original_path:
try:
with open(original_path, "rb") as source:
return source.read()
except OSError:
return None
return None
def _jpeg_bytes(image: Image.Image) -> bytes:
working = image if image.mode == "RGB" else image.convert("RGB")
buffered = BytesIO()
working.save(
buffered,
format="JPEG",
quality=95,
optimize=True,
subsampling=2,
)
return buffered.getvalue()
def image_to_upload_payload(image: Image.Image) -> Tuple[Any, str, str]:
"""Return the exact file payload used by the shared temporary uploader."""
source_format = _normalize_image_format(
getattr(image, "_o1key_original_format", None) or getattr(image, "format", None)
)
original_path = getattr(image, "_o1key_original_path", None)
if source_format in {"JPEG", "PNG"} and original_path and os.path.isfile(original_path):
if source_format == "JPEG":
return original_path, ".jpg", "image/jpeg"
return original_path, ".png", "image/png"
if source_format == "JPEG":
return (
_original_image_bytes(image, "JPEG") or _jpeg_bytes(image),
".jpg",
"image/jpeg",
)
return (
_original_image_bytes(image, "PNG") or _png_bytes(image),
".png",
"image/png",
)
# Retain the former private helper for internal callers and offline tests that
# predate the public validation hook.
_image_to_upload_payload = image_to_upload_payload
def _image_mime_type(data: bytes) -> str:
"""Return the MIME type proven by the encoded image bytes."""
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
raise ValueError("Nano Banana: 参考图仅支持内联 PNG 或 JPEG 数据")
def _image_to_inline_data(image: Image.Image) -> Dict[str, Dict[str, str]]:
"""Encode one reference using the provider's exact inlineData schema."""
payload, _extension, _declared_content_type = image_to_upload_payload(image)
if isinstance(payload, (str, os.PathLike)):
with open(payload, "rb") as source:
image_bytes = source.read()
else:
image_bytes = bytes(payload)
mime_type = _image_mime_type(image_bytes)
return {
"inlineData": {
"mimeType": mime_type,
"data": base64.b64encode(image_bytes).decode("ascii"),
}
}
async def encode_nano_banana_images_inline(
images: Sequence[Image.Image],
inline_cache: Optional[Dict[int, Awaitable[Dict[str, Dict[str, str]]]]] = None,
) -> List[Dict[str, Dict[str, str]]]:
"""Encode references once per run and preserve their submitted order."""
pending_items: List[Awaitable[Dict[str, Dict[str, str]]]] = []
cache_entries: List[Tuple[int, Awaitable[Dict[str, Dict[str, str]]]]] = []
for image in images:
cache_key = id(image)
pending = inline_cache.get(cache_key) if inline_cache is not None else None
if pending is None:
pending = asyncio.create_task(asyncio.to_thread(_image_to_inline_data, image))
if inline_cache is not None:
inline_cache[cache_key] = pending
pending_items.append(pending)
cache_entries.append((cache_key, pending))
results = await asyncio.gather(*pending_items, return_exceptions=True)
inline_images: List[Dict[str, Dict[str, str]]] = []
for result, (cache_key, pending) in zip(results, cache_entries):
if isinstance(result, BaseException):
if inline_cache is not None and inline_cache.get(cache_key) is pending:
inline_cache.pop(cache_key, None)
raise result
inline_images.append(result)
return inline_images
def _retry_after_seconds(value: Optional[str], fallback: float) -> float:
try:
return max(0.0, min(float(value), 120.0))
except (TypeError, ValueError):
return fallback
async def _upload_nano_banana_temp_image(
session: Any,
base_url: str,
api_key: str,
data: Any,
filename: str,
node_label: str,
content_type: str = "image/png",
check_interrupt: Optional[Callable[[], None]] = None,
) -> str:
"""Upload one reference image and return its temporary HTTPS URL."""
upload_url = f"{base_url.rstrip('/')}{_TEMP_UPLOAD_ENDPOINT}"
last_error: Optional[BaseException] = None
for attempt in range(len(_UPLOAD_RETRY_DELAYS) + 1):
if check_interrupt:
check_interrupt()
try:
retry_delay: Optional[float] = None
source = open(data, "rb") if isinstance(data, (str, os.PathLike)) else data
try:
async with session.post(
upload_url,
files={"file": (filename, source, content_type)},
headers={"Authorization": f"Bearer {api_key}"},
timeout=_UPLOAD_TIMEOUT,
) as response:
text = await response.text()
if response.status in (200, 201):
try:
payload = json.loads(text)
except Exception:
raise RuntimeError(
f"{node_label}: 临时素材上传响应不是有效 JSON"
) from None
public_url = str(payload.get("url") or "").strip()
if not public_url.startswith("https://"):
raise RuntimeError(
f"{node_label}: 临时素材上传响应缺少有效 HTTPS URL"
)
return public_url
retryable = response.status == 429 or 500 <= response.status < 600
if not retryable or attempt >= len(_UPLOAD_RETRY_DELAYS):
retry_after = response.headers.get("Retry-After")
suffix = f",Retry-After={retry_after}s" if retry_after else ""
raise RuntimeError(
f"{node_label}: 临时素材上传失败 HTTP {response.status}{suffix}"
)
fallback = _UPLOAD_RETRY_DELAYS[attempt]
retry_delay = _retry_after_seconds(response.headers.get("Retry-After"), fallback)
print(
f"{node_label}: 临时素材上传 HTTP {response.status},{retry_delay:.1f}s 后重试 "
f"({attempt + 1}/{len(_UPLOAD_RETRY_DELAYS)})"
)
finally:
if source is not data:
source.close()
if retry_delay is not None:
await _interruptible_sleep(retry_delay, check_interrupt=check_interrupt)
continue
except asyncio.CancelledError:
raise
except RuntimeError:
raise
except _NETWORK_ERRORS as exc:
last_error = exc
if attempt >= len(_UPLOAD_RETRY_DELAYS):
break
delay = _UPLOAD_RETRY_DELAYS[attempt]
print(
f"{node_label}: 临时素材上传连接异常,{delay:.1f}s 后重试 "
f"({attempt + 1}/{len(_UPLOAD_RETRY_DELAYS)}): {exc}"
)
await _interruptible_sleep(delay, check_interrupt=check_interrupt)
raise RuntimeError(
f"{node_label}: 临时素材上传网络重试耗尽: "
f"{type(last_error).__name__}: {last_error}"
) from None
async def upload_nano_banana_images_to_temp_urls(
session: Any,
base_url: str,
api_key: str,
images: Sequence[Image.Image],
node_label: str = "Nano Banana",
check_interrupt: Optional[Callable[[], None]] = None,
upload_cache: Optional[Dict[int, Awaitable[str]]] = None,
log_success: bool = True,
) -> List[str]:
"""Upload references as temporary public URLs, reusing uploads in one run."""
total = len(images)
if total == 0:
return []
pending_uploads: List[Awaitable[str]] = []
cache_entries: List[Tuple[int, Awaitable[str]]] = []
for index, image in enumerate(images, start=1):
if check_interrupt:
check_interrupt()
cache_key = id(image)
pending_upload = upload_cache.get(cache_key) if upload_cache is not None else None
if pending_upload is None:
async def _prepare_and_upload(
source: Image.Image = image,
image_index: int = index,
) -> str:
data, extension, content_type = await asyncio.to_thread(
image_to_upload_payload,
source,
)
return await _upload_nano_banana_temp_image(
session=session,
base_url=base_url,
api_key=api_key,
data=data,
filename=f"reference_{image_index}{extension}",
node_label=node_label,
content_type=content_type,
check_interrupt=check_interrupt,
)
pending_upload = asyncio.create_task(_prepare_and_upload())
if upload_cache is not None:
upload_cache[cache_key] = pending_upload
pending_uploads.append(pending_upload)
cache_entries.append((cache_key, pending_upload))
results = await asyncio.gather(*pending_uploads, return_exceptions=True)
failures = []
urls: List[str] = []
for index, (result, (cache_key, pending_upload)) in enumerate(zip(results, cache_entries), start=1):
if isinstance(result, BaseException):
if upload_cache is not None and upload_cache.get(cache_key) is pending_upload:
upload_cache.pop(cache_key, None)
failures.append(result)
continue
urls.append(str(result))
if log_success:
print(f"{node_label}: 参考图临时 URL 已就绪 ({index}/{total})")
if failures:
raise failures[0]
return urls
# 兼容仍引用旧内部名称的调用方;实际已改为临时素材上传接口。
upload_nano_banana_images_to_oss = upload_nano_banana_images_to_temp_urls
def _shorten_base64_string_for_log(value: str, max_len: int) -> str:
"""Collapse data URIs and raw base64 values before writing diagnostic logs."""
header, separator, data = value.partition(",")
if separator and header.lower().startswith("data:") and ";base64" in header.lower():
return f"{header},<base64 data, {len(data)} chars>"
candidate = value.strip()
if len(candidate) % 4 == 0:
try:
base64.b64decode(candidate, validate=True)
except Exception:
pass
else:
return f"<base64 data, {len(candidate)} chars>"
return value
def _shorten_base64_for_log(value: Any, max_len: int = 160) -> Any:
if isinstance(value, dict):
result = {}
for key, item in value.items():
if key in ("data", "b64_json", "base64", "image_base64") and isinstance(item, str):
result[key] = f"<base64 data, {len(item)} chars>"
else:
result[key] = _shorten_base64_for_log(item, max_len)
return result
if isinstance(value, list):
return [_shorten_base64_for_log(item, max_len) for item in value]
if isinstance(value, str):
return _shorten_base64_string_for_log(value, max_len)
return value
def _log_body(label: str, text_or_body: Any) -> None:
if isinstance(text_or_body, str):
try:
text_or_body = json.loads(text_or_body)
except Exception:
body_size = len(text_or_body.encode("utf-8", errors="replace"))
print(f"{label}\n<unparseable response body, {body_size} bytes; content omitted>")
return
print(
f"{label}\n"
f"{json.dumps(_shorten_base64_for_log(text_or_body), ensure_ascii=False, indent=2)}"
)
def build_nano_banana_submit_body(
model: str,
prompt: str,
resolution: str,
aspect_ratio: str,
images: Optional[List[Image.Image]] = None,
image_urls: Optional[List[str]] = None,
inline_images: Optional[List[Dict[str, Dict[str, str]]]] = None,
thinking_level: Optional[str] = None,
request_log_enabled: bool = False,
node_label: str = "Nano Banana",
google_search: bool = False,
) -> Dict[str, Any]:
def _make_body(reference_images: List[Dict[str, Dict[str, str]]]) -> Dict[str, Any]:
body: Dict[str, Any] = {
"model": model,
"prompt": prompt,
}
if resolution != "智能":
body["size"] = {"512": "512px"}.get(resolution, resolution)
if aspect_ratio and aspect_ratio != "智能":
body["aspect_ratio"] = aspect_ratio
if reference_images:
body["images"] = reference_images
if thinking_level:
body["thinking_level"] = thinking_level
if google_search:
body["google_search"] = True
return body
provided_image_urls = [str(url).strip() for url in (image_urls or []) if str(url).strip()]
if provided_image_urls:
raise ValueError(f"{node_label}: 参考图必须使用 inlineData,不能提交 URL")
if images and inline_images is not None:
raise ValueError(f"{node_label}: 参考图不能同时提供原图和已编码 inlineData")
prepared_inline_images = (
list(inline_images)
if inline_images is not None
else [_image_to_inline_data(image) for image in (images or [])]
)
body = _make_body(prepared_inline_images)
body_size = _json_size(body)
if request_log_enabled:
print(
f"[{node_label} 异步请求体] {body_size / 1024:.1f}KB\n"
f"{json.dumps(_shorten_base64_for_log(body), ensure_ascii=False, indent=2)}"
)
return body
def _nano_banana_body_size_for_inline_images(
*,
model: str,
prompt: str,
resolution: str,
aspect_ratio: str,
inline_images: List[Dict[str, Dict[str, str]]],
thinking_level: Optional[str],
google_search: bool = False,
) -> int:
body = build_nano_banana_submit_body(
model=model,
prompt=prompt,
resolution=resolution,
aspect_ratio=aspect_ratio,
inline_images=inline_images,
thinking_level=thinking_level,
google_search=google_search,
)
return _json_size(body)
def validate_nano_banana_request_body(
body: Dict[str, Any],
*,
node_label: str = "Nano Banana",
) -> int:
"""Enforce the local 18 MiB safety ceiling on the exact UTF-8 JSON body."""
body_size = _json_size(body)
if body_size > NANO_BANANA_REQUEST_BODY_LIMIT_BYTES:
raise ValueError(
f"{node_label}: 请求体 {body_size / (1024 * 1024):.2f} MiB 超过 "
f"{NANO_BANANA_REQUEST_BODY_LIMIT_BYTES / (1024 * 1024):.0f} MiB 上限,"
"请减少参考图、在上游缩小图片,或选择“智能缩放”"
)
return body_size
def _resized_reference_from_original(
image: Image.Image,
scale: float,
) -> Image.Image:
width = max(1, round(image.width * scale))
height = max(1, round(image.height * scale))
resized = image.resize((width, height), Image.Resampling.LANCZOS)
source_format = _normalize_image_format(
getattr(image, "_o1key_original_format", None) or getattr(image, "format", None)
)
encoded_format = "JPEG" if source_format == "JPEG" else "PNG"
resized.format = encoded_format
setattr(resized, "_o1key_original_format", encoded_format)
return resized
def _fit_inline_images_to_request_limit(
*,
images: Sequence[Image.Image],
original_inline_images: List[Dict[str, Dict[str, str]]],
model: str,
prompt: str,
resolution: str,
aspect_ratio: str,
thinking_level: Optional[str],
check_interrupt: Optional[Callable[[], None]] = None,
google_search: bool = False,
) -> Tuple[List[Dict[str, Dict[str, str]]], int, List[Tuple[int, int]]]:
"""Shrink the largest encoded references first, always resampling from originals."""
inline_images = list(original_inline_images)
scales = [1.0] * len(images)
final_sizes = [image.size for image in images]
for _attempt in range(_SMART_RESIZE_MAX_ATTEMPTS):
if check_interrupt:
check_interrupt()
body_size = _nano_banana_body_size_for_inline_images(
model=model,
prompt=prompt,
resolution=resolution,
aspect_ratio=aspect_ratio,
inline_images=inline_images,
thinking_level=thinking_level,
google_search=google_search,
)
if body_size <= NANO_BANANA_REQUEST_BODY_LIMIT_BYTES:
return inline_images, body_size, final_sizes
candidates = []
for index, (image, item, current_scale) in enumerate(
zip(images, inline_images, scales)
):
min_scale = min(1.0, _SMART_RESIZE_MIN_LONG_EDGE / max(image.size))
if current_scale <= min_scale + 1e-6:
continue
data_length = len(item["inlineData"]["data"])
candidates.append((data_length, index, min_scale))
if not candidates:
break
current_data_length, index, min_scale = max(candidates)
overflow = body_size - NANO_BANANA_REQUEST_BODY_LIMIT_BYTES
desired_data_length = max(1024, current_data_length - overflow - 32 * 1024)
estimated_area_ratio = min(0.90, desired_data_length / current_data_length)
proposed_scale = scales[index] * math.sqrt(max(0.01, estimated_area_ratio)) * 0.98
new_scale = max(min_scale, min(scales[index] * 0.92, proposed_scale))
original = images[index]
previous_size = final_sizes[index]
candidate = _resized_reference_from_original(original, new_scale)
if candidate.size == previous_size:
candidate.close()
new_scale = max(min_scale, scales[index] * 0.85)
candidate = _resized_reference_from_original(original, new_scale)
try:
inline_images[index] = _image_to_inline_data(candidate)
scales[index] = new_scale
final_sizes[index] = candidate.size
finally:
candidate.close()
final_body_size = _nano_banana_body_size_for_inline_images(
model=model,
prompt=prompt,
resolution=resolution,
aspect_ratio=aspect_ratio,
inline_images=inline_images,
thinking_level=thinking_level,
google_search=google_search,
)
raise ValueError(
f"Nano Banana: 智能缩放后请求体仍为 {final_body_size / (1024 * 1024):.2f} MiB,"
f"无法安全降至 {NANO_BANANA_REQUEST_BODY_LIMIT_BYTES / (1024 * 1024):.0f} MiB;"
"请减少参考图或在上游进一步缩小"
)
async def prepare_nano_banana_inline_images(
images: Sequence[Image.Image],
*,
model: str,
prompt: str,
resolution: str,
aspect_ratio: str,
thinking_level: Optional[str] = None,
resize_mode: str = "不缩放",
inline_cache: Optional[Dict[int, Awaitable[Dict[str, Dict[str, str]]]]] = None,
check_interrupt: Optional[Callable[[], None]] = None,
node_label: str = "Nano Banana",
google_search: bool = False,
) -> List[Dict[str, Dict[str, str]]]:
"""Encode references and optionally fit their exact JSON body below 18 MiB."""
if resize_mode not in NANO_BANANA_RESIZE_MODES:
raise ValueError(f"{node_label}: 缩放图片参数无效")
inline_images = await encode_nano_banana_images_inline(images, inline_cache=inline_cache)
body_size = _nano_banana_body_size_for_inline_images(
model=model,
prompt=prompt,
resolution=resolution,
aspect_ratio=aspect_ratio,
inline_images=inline_images,
thinking_level=thinking_level,
google_search=google_search,
)
if body_size <= NANO_BANANA_REQUEST_BODY_LIMIT_BYTES:
return inline_images
if resize_mode == "不缩放":
raise ValueError(
f"{node_label}: 请求体 {body_size / (1024 * 1024):.2f} MiB 超过 "
f"{NANO_BANANA_REQUEST_BODY_LIMIT_BYTES / (1024 * 1024):.0f} MiB 上限;"
"当前设置为“不缩放”,请在上游缩小图片或改用“智能缩放”"
)
fitted, fitted_size, final_sizes = await asyncio.to_thread(
_fit_inline_images_to_request_limit,
images=images,
original_inline_images=inline_images,
model=model,
prompt=prompt,
resolution=resolution,
aspect_ratio=aspect_ratio,
thinking_level=thinking_level,
google_search=google_search,
check_interrupt=check_interrupt,
)
changed = [
f"#{index + 1} {source.width}×{source.height}→{size[0]}×{size[1]}"
for index, (source, size) in enumerate(zip(images, final_sizes))
if source.size != size
]
if changed:
print(
f"{node_label}: 智能缩放完成 | "
f"{body_size / (1024 * 1024):.2f}→{fitted_size / (1024 * 1024):.2f} MiB | "
+ ",".join(changed)
)
return fitted
async def _interruptible_sleep(
seconds: float,
check_interrupt: Optional[Callable[[], None]] = None,
) -> None:
elapsed = 0.0
while elapsed < seconds:
if check_interrupt:
check_interrupt()
delay = min(_INTERRUPT_STEP, seconds - elapsed)
await asyncio.sleep(delay)
elapsed += delay
if check_interrupt:
check_interrupt()
def _payload_sources(payload: Dict[str, Any]) -> Iterable[Dict[str, Any]]:
queue = [payload]
seen = set()
while queue:
current = queue.pop(0)
if not isinstance(current, dict):
continue
obj_id = id(current)
if obj_id in seen:
continue
seen.add(obj_id)
yield current
for key in ("data", "result", "response", "output", "task_result", "content"):
value = current.get(key)
if isinstance(value, dict):
queue.append(value)
def _extract_task_id(payload: Dict[str, Any]) -> str:
for source in _payload_sources(payload):
for key in ("task_id", "taskId", "id"):
value = source.get(key)
if value:
return str(value)
raise RuntimeError(f"提交响应中未找到 task_id: {payload}")
def _extract_status(payload: Dict[str, Any]) -> str:
statuses = []
for source in _payload_sources(payload):
for key in ("status", "task_status", "state", "task_state"):
value = source.get(key)
if value is not None and str(value).strip():
statuses.append(str(value).strip())
for status in statuses:
normalized = status.lower()
if normalized in _FAILURE_STATUSES or any(
token in normalized for token in ("fail", "error", "reject", "timeout", "cancel")
):
return status
for status in statuses:
if status.lower() in _RUNNING_STATUSES:
return status
for status in statuses:
if status.lower() in _SUCCESS_STATUSES:
return status
return statuses[0] if statuses else ""
def _coerce_progress_fraction(value: Any) -> Optional[float]:
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
progress = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return None
has_percent_suffix = text.endswith("%")
if has_percent_suffix:
text = text[:-1].strip()
try:
progress = float(text)
except ValueError:
return None
if has_percent_suffix:
progress /= 100.0
else:
return None
if progress > 1.0:
progress /= 100.0
return max(0.0, min(progress, 1.0))
def _extract_progress(payload: Dict[str, Any]) -> Optional[float]:
for source in _payload_sources(payload):
for key in ("progress", "percentage", "percent"):
progress = _coerce_progress_fraction(source.get(key))
if progress is not None:
return progress
for key in ("progressInfo", "progress_info"):
info = source.get(key)
if not isinstance(info, dict):
continue
for field in ("progress", "percentage", "percent"):
progress = _coerce_progress_fraction(info.get(field))
if progress is not None:
return progress
return None
def _extract_result_urls(payload: Dict[str, Any]) -> List[str]:
urls: List[str] = []
seen = set()
def _add(value: Any) -> None:
if isinstance(value, str) and value.startswith("http") and value not in seen:
seen.add(value)
urls.append(value)
for source in _payload_sources(payload):
for key in ("image_url", "result_url", "url", "download_url"):
_add(source.get(key))
for key in ("images", "output_images", "outputs"):
value = source.get(key)
if not isinstance(value, list):
continue
for item in value:
if isinstance(item, str):
_add(item)
elif isinstance(item, dict):
for url_key in ("url", "image_url", "result_url", "download_url"):
_add(item.get(url_key))
return urls
def _is_failure_status(normalized_status: str) -> bool:
return normalized_status in _FAILURE_STATUSES or any(
token in normalized_status for token in ("fail", "error", "reject", "timeout", "cancel")
)
def _extract_error_message(payload: Dict[str, Any]) -> str:
for source in _payload_sources(payload):
error = source.get("error")
if isinstance(error, dict):
for key in ("message", "msg", "detail", "reason", "code"):
value = error.get(key)
if value:
return str(value)
elif error:
message = extract_structured_error_message(str(error))
return message or str(error)
for key in (
"fail_reason",
"failure_reason",
"task_status_msg",
"status_msg",
"error_message",
"message",
"msg",
"reason",
"detail",
):
value = source.get(key)
if value:
message = extract_structured_error_message(str(value))
return message or str(value)
return "未知错误"
def _format_task_failure(payload: Dict[str, Any], eagleid: str = "") -> str:
"""把失败 payload 拼成带 upstream_status / code / category / task_id 的错误文案。"""
error_detail = extract_error_detail(payload)
status_code = extract_error_status_code(error_detail)
code = str(error_detail.get("code") or "")
category = str(error_detail.get("category") or "")
task_id = str(error_detail.get("task_id") or payload.get("task_id") or "")
parts = []
if status_code is not None:
parts.append(f"upstream_status={status_code}")
if code:
parts.append(f"code={code}")
if category:
parts.append(f"category={category}")
if task_id:
parts.append(f"task_id={task_id}")
detail_text = f" ({', '.join(parts)})" if parts else ""
return _append_eagleid(f"任务失败: {_extract_error_message(payload)}{detail_text}", eagleid)
async def _submit_task(
session: Any,
base_url: str,
api_key: str,
body: Dict[str, Any],
node_label: str,
log_body_enabled: bool = False,
log_success: bool = True,
) -> str:
url = f"{base_url}{_SUBMIT_ENDPOINT}"
timeout = create_timeout(
120.0, connect=30.0, read=120.0, write=30.0, pool=30.0
)
try:
if log_body_enabled:
print(f"[{node_label} 异步提交请求] POST {url}")
async with session.post(
url,
headers=_headers(api_key),
data=_json_dumps(body).encode("utf-8"),
timeout=timeout,
) as resp:
eagleid = _extract_response_eagleid(resp)
if log_body_enabled:
_log_response_headers(f"{node_label} 异步提交响应头", resp)
try:
text = await resp.text()
except HTTP_STREAM_ERRORS as exc:
if not log_body_enabled:
_log_response_headers(f"{node_label} 异步提交响应头(读取失败)", resp)
raise RuntimeError(
_append_eagleid(f"提交响应读取失败: {exc}", eagleid)
) from None
if log_body_enabled or RESPONSE_LOG_ENABLED:
_log_body(f"[{node_label} 异步提交响应] HTTP {resp.status}", text)
if resp.status not in (200, 201, 202):
if not log_body_enabled:
_log_response_headers(f"{node_label} 异步提交错误响应头", resp)
_log_body(f"[{node_label} 异步提交错误响应] HTTP {resp.status}", text)
raise RuntimeError(_append_eagleid(get_friendly_message(resp.status, text), eagleid))
try:
data = json.loads(text)
except Exception:
if not log_body_enabled:
_log_response_headers(f"{node_label} 异步提交异常响应头", resp)
_log_body(f"[{node_label} 异步提交异常响应] HTTP {resp.status}", text)
raise RuntimeError(
_append_eagleid(f"提交响应 JSON 解析失败: {text[:500]}", eagleid)
) from None
try:
task_id = _extract_task_id(data)
except Exception as exc:
raise RuntimeError(_append_eagleid(str(exc), eagleid)) from None
if log_success:
print(f"{node_label}: 已提交 | task_id={task_id}")
return task_id
except _NETWORK_ERRORS as e:
raise RuntimeError(
f"网络连接失败,无法连接 {url}: {str(e)}。请在设置 → 用户 → API密钥设置中切换全局线路,或检查 VPN/代理/防火墙。"
) from None
async def _poll_task(
session: Any,
base_url: str,
api_key: str,
task_id: str,
node_label: str,
check_interrupt: Optional[Callable[[], None]] = None,
log_body_enabled: bool = False,
progress_callback: Optional[Callable[[float], None]] = None,
log_success: bool = True,
initial_delay: bool = True,
) -> Dict[str, Any]:
url = f"{base_url}{_TASK_ENDPOINT.format(task_id=task_id)}"
start_time = time.time()
last_poll_at = start_time
poll_count = 0
last_logged_status = ""
last_logged_progress_bucket = -1
# Stable per-task offset prevents concurrent jobs from polling in one burst.
poll_jitter = (sum(task_id.encode("utf-8")) % 750) / 1000.0
while True:
if check_interrupt:
check_interrupt()
if poll_count == 0 and not initial_delay:
next_poll_at = start_time
elif poll_count < len(_POLL_SCHEDULE):
next_poll_at = start_time + _POLL_SCHEDULE[poll_count] + poll_jitter
else:
next_poll_at = last_poll_at + _POLL_INTERVAL
sleep_time = next_poll_at - time.time()
if sleep_time > 0:
await _interruptible_sleep(sleep_time, check_interrupt=check_interrupt)
last_poll_at = time.time()
elapsed = last_poll_at - start_time
if elapsed > _MAX_WAIT_SECONDS:
raise RuntimeError(f"任务 {task_id} 超时(>{int(_MAX_WAIT_SECONDS)}秒),请稍后用 task_id 查询结果")
poll_count += 1
payload = None
text = ""
eagleid = ""
response = None
for retry_index in range(len(_POLL_TRANSPORT_RETRY_DELAYS) + 1):
response = None
eagleid = ""
try:
if log_body_enabled:
retry_text = f" retry={retry_index}" if retry_index else ""
print(f"[{node_label} 任务查询请求 #{poll_count}{retry_text}] GET {url}")
async with session.get(
url,
headers=_headers(api_key),
timeout=_POLL_REQUEST_TIMEOUT,
) as resp:
response = resp
eagleid = _extract_response_eagleid(resp)
if log_body_enabled:
_log_response_headers(
f"{node_label} 任务查询响应头 #{poll_count} retry={retry_index}",
resp,
)
body, diagnostics = await read_response_body_with_diagnostics(resp)
text = body.decode("utf-8")
if log_body_enabled or RESPONSE_LOG_ENABLED:
_log_body(
f"[{node_label} 任务查询响应 #{poll_count} retry={retry_index}] HTTP {resp.status}",
text,
)
if resp.status != 200:
print(
f"[{node_label}] 任务查询传输追踪"
f" | requested_task_id={task_id}"
f" | response_task_id=<not-parsed>"
f" | {format_response_body_diagnostics(diagnostics)}"
f" | json=not-parsed | Eagleid={eagleid or '<none>'}"
)
if not log_body_enabled:
_log_response_headers(f"{node_label} 任务查询错误响应头 #{poll_count}", resp)
_log_body(f"[{node_label} 任务查询错误响应 #{poll_count}] HTTP {resp.status}", text)
raise RuntimeError(
_append_eagleid(get_friendly_message(resp.status, text), eagleid)
)
try:
payload = json.loads(text)
except json.JSONDecodeError:
print(
f"[{node_label}] 任务查询传输追踪"
f" | requested_task_id={task_id}"
f" | response_task_id=<unavailable>"
f" | {format_response_body_diagnostics(diagnostics)}"
f" | json=invalid | Eagleid={eagleid or '<none>'}"
)
if not log_body_enabled:
_log_response_headers(f"{node_label} 任务查询异常响应头 #{poll_count}", resp)
_log_body(f"[{node_label} 任务查询异常响应 #{poll_count}] HTTP {resp.status}", text)
# A valid HTTP status with an incomplete body can still
# fail JSON decoding. Let the safe GET retry handle it.
raise
actual_task_id = response_task_id(payload)
if actual_task_id is not None and actual_task_id != task_id:
print(
f"[{node_label}] 任务查询传输追踪"
f" | requested_task_id={task_id}"
f" | response_task_id={actual_task_id}"
f" | task_id_check=mismatch"
f" | {format_response_body_diagnostics(diagnostics)}"
f" | json=valid | Eagleid={eagleid or '<none>'}"
)
validate_response_task_id(payload, task_id)
break
except _POLL_RETRY_ERRORS as exc:
if retry_index >= len(_POLL_TRANSPORT_RETRY_DELAYS):
if response is not None and not log_body_enabled:
_log_response_headers(
f"{node_label} 任务查询响应头 #{poll_count}(重试耗尽)",
response,
)
raise RuntimeError(
f"轮询响应传输失败 | task_id={task_id} | poll=#{poll_count} | "
f"retries={retry_index} | Eagleid={eagleid or '<none>'} | "
f"{type(exc).__name__}: {exc}"
) from None
delay = _POLL_TRANSPORT_RETRY_DELAYS[retry_index]
print(
f"{node_label}: 轮询传输异常,{delay:.0f}s 后重试 "
f"| task_id={task_id} | poll=#{poll_count} "
f"| retry={retry_index + 1}/{len(_POLL_TRANSPORT_RETRY_DELAYS)} "
f"| Eagleid={eagleid or '<none>'} | {type(exc).__name__}: {exc}"
)
await _interruptible_sleep(delay, check_interrupt=check_interrupt)
if payload is None:
raise RuntimeError(f"任务查询未返回有效结果 | task_id={task_id} | poll=#{poll_count}")
status = _extract_status(payload) or "UNKNOWN"
normalized = status.lower()
progress = _extract_progress(payload)
is_failure = _is_failure_status(normalized)
if normalized in _RUNNING_STATUSES and _POLL_LOG_ENABLED:
progress_bucket = int(progress * 10) if progress is not None else -1
should_log = (
normalized != last_logged_status
or progress_bucket != last_logged_progress_bucket
or poll_count % 20 == 0
)
if should_log:
progress_text = f" | progress={progress * 100:.0f}%" if progress is not None else ""
print(
f"{node_label}: 处理中 | task_id={task_id} | poll=#{poll_count}"
f"{progress_text}"
)
last_logged_status = normalized
last_logged_progress_bucket = progress_bucket
if normalized in _SUCCESS_STATUSES:
if progress_callback:
progress_callback(1.0)
if log_success:
print(
f"{node_label}: 成功 | task_id={task_id} | polls={poll_count} "
f"| elapsed={time.time() - start_time:.1f}s"
)
return payload
if is_failure:
if response is not None and not log_body_enabled:
_log_response_headers(f"{node_label} 任务失败响应头 #{poll_count}", response)
_log_body(f"[{node_label} 任务失败响应 #{poll_count}] HTTP {response.status}", text)
raise RuntimeError(
f"{_format_task_failure(payload, eagleid=eagleid)} | poll=#{poll_count}"
)
if normalized not in _RUNNING_STATUSES:
if response is not None and not log_body_enabled:
_log_response_headers(f"{node_label} 未知状态响应头 #{poll_count}", response)
_log_body(f"[{node_label} 未知状态响应 #{poll_count}] HTTP {response.status}", text)
raise RuntimeError(_append_eagleid(f"未知任务状态 {status}: {payload}", eagleid))
if progress_callback and progress is not None:
progress_callback(min(progress, _RUNNING_PROGRESS_MAX))
async def _download_image_bytes(
value: str,
session: Any,
download_semaphore: Optional[asyncio.Semaphore] = None,
check_interrupt: Optional[Callable[[], None]] = None,
validator: Optional[Callable[[bytes], Any]] = None,
return_metrics: bool = False,
return_validated: bool = False,
) -> Any:
download_started = time.perf_counter()
async def _request_once() -> Tuple[bytes, Any]:
if check_interrupt:
check_interrupt()
async with session.get(
value,
allow_redirects=True,
timeout=_DOWNLOAD_TIMEOUT,
) as resp:
if resp.status != 200:
raise RuntimeError(f"Image download failed ({resp.status})")
buffer = bytearray()
async for chunk in resp.content.iter_chunked(_DOWNLOAD_CHUNK_SIZE):
if check_interrupt:
check_interrupt()
buffer.extend(chunk)
image_bytes = bytes(buffer)
validated = None
if validator is not None:
# Verify the complete image inside the retry scope. PIL reads
# lazily unless load() is called, so malformed/truncated image
# bytes would otherwise escape the transport retry path.
validated = await asyncio.to_thread(validator, image_bytes)
return image_bytes, validated
last_error: Optional[BaseException] = None
for attempt in range(len(_DOWNLOAD_RETRY_DELAYS) + 1):
try:
if download_semaphore is None:
image_bytes, validated = await _request_once()
else:
async with download_semaphore:
image_bytes, validated = await _request_once()
elapsed = time.perf_counter() - download_started
if return_metrics and return_validated:
return image_bytes, elapsed, validated
if return_metrics:
return image_bytes, elapsed
if return_validated:
return image_bytes, validated
return image_bytes
except asyncio.CancelledError:
raise
except _DOWNLOAD_ERRORS as exc:
last_error = exc
if attempt >= len(_DOWNLOAD_RETRY_DELAYS):
break
await _interruptible_sleep(
_DOWNLOAD_RETRY_DELAYS[attempt],
check_interrupt=check_interrupt,
)
raise RuntimeError(
f"Image download transport failed after {len(_DOWNLOAD_RETRY_DELAYS) + 1} attempts: "
f"{type(last_error).__name__}: {last_error}"
) from None
def _format_transfer_size(size: int) -> str:
"""Format a byte count for compact, human-readable download logs."""
if size < 1024 * 1024:
return f"{size / 1024:.1f} KiB"
return f"{size / 1024 / 1024:.2f} MiB"
def _format_transfer_rate(size: int, elapsed: float) -> str:
if elapsed <= 0:
return "∞ MiB/s"
return f"{size / 1024 / 1024 / elapsed:.2f} MiB/s"
async def _image_from_url_or_data(
value: str,
session: Any,
download_semaphore: Optional[asyncio.Semaphore] = None,
check_interrupt: Optional[Callable[[], None]] = None,
) -> Optional[Image.Image]:
if not value:
return None
if value.startswith("data:image"):
try:
_, b64_data = value.split(",", 1)
raw_bytes = await asyncio.to_thread(base64.b64decode, b64_data)
return await asyncio.to_thread(_open_result_image, raw_bytes)
except RuntimeError:
raise
except Exception as exc:
raise RuntimeError(f"data URL image decode failed: {exc}") from None
if value.startswith("http"):
_, image = await _download_image_bytes(
value,
session,
download_semaphore=download_semaphore,
check_interrupt=check_interrupt,
validator=_open_result_image,
return_validated=True,
)
return image
return None
async def _parse_direct_images(
payload: Dict[str, Any],
session: Any,
download_semaphore: Optional[asyncio.Semaphore] = None,
check_interrupt: Optional[Callable[[], None]] = None,
result_url_callback: Optional[Callable[[str], None]] = None,
node_label: str = "Nano Banana",
log_downloads: bool = False,
return_metrics: bool = False,
) -> Any:
candidates: List[Tuple[str, str]] = []
seen = set()
def _add_candidate(kind: str, value: Any) -> None:
normalized = str(value or "")
if not normalized:
return
key = (kind, normalized)
if key in seen:
return
seen.add(key)
candidates.append(key)
def _add_item(item: Any) -> None:
if isinstance(item, str):
_add_candidate("value", item)
return
if not isinstance(item, dict):
return
for key in ("url", "image_url", "result_url", "download_url"):
if item.get(key):
_add_candidate("value", item[key])
return
b64_data = item.get("b64_json") or item.get("base64") or item.get("image_base64")
if b64_data:
_add_candidate("base64", b64_data)
return
for inline_key in ("inline_data", "inlineData"):
inline = item.get(inline_key)
if isinstance(inline, dict) and inline.get("data"):
_add_candidate("base64", inline["data"])
return
for source in _payload_sources(payload):
for key in ("image_url", "result_url", "url", "download_url"):
if source.get(key):
_add_candidate("value", source[key])
for key in ("images", "output_images", "outputs"):
value = source.get(key)
if isinstance(value, list):
for item in value:
_add_item(item)
elif value:
_add_item(value)
metrics = {
"download_bytes": 0,
"download_seconds": 0.0,
"download_wall_seconds": 0.0,
"inline_images": 0,
}
total = len(candidates)
async def _load_candidate(index: int, kind: str, value: str) -> Optional[Image.Image]:
if kind == "base64":
started = time.perf_counter()
raw_bytes, image = await asyncio.to_thread(
_decode_inline_result_image,
value,
f"第 {index} 张内联 Base64 图片",
)
elapsed = time.perf_counter() - started
metrics["inline_images"] += 1
if log_downloads:
print(
f"{node_label}: 结果图像 {index}/{total} 内联 base64,无网络下载 | "
f"{image.width}×{image.height} | {_format_transfer_size(len(raw_bytes))} | 解码={elapsed:.2f}s"
)
return image
if value.startswith("data:image"):
try:
header, separator, b64_data = value.partition(",")
if not separator or ";base64" not in header.lower():
raise _IncompleteInlineImageError(
f"第 {index} 张内联 data URL 缺少有效 Base64 头"
)
started = time.perf_counter()
raw_bytes, image = await asyncio.to_thread(
_decode_inline_result_image,
b64_data,
f"第 {index} 张内联 data URL 图片",
)
elapsed = time.perf_counter() - started
except _IncompleteInlineImageError:
raise
except Exception as exc:
raise _IncompleteInlineImageError(
f"第 {index} 张内联 data URL 图片不完整或无法解码: {exc}"
) from None
metrics["inline_images"] += 1
if log_downloads:
print(
f"{node_label}: 结果图像 {index}/{total} 内联 data URL,无网络下载 | "
f"{image.width}×{image.height} | {_format_transfer_size(len(raw_bytes))} | 解码={elapsed:.2f}s"
)
return image
if not value.startswith("http"):
return None
if log_downloads:
print(f"{node_label}: 下载图像 {index}/{total} 开始")
img_bytes, elapsed, image = await _download_image_bytes(
value,
session,
download_semaphore=download_semaphore,
check_interrupt=check_interrupt,
validator=_open_result_image,
return_metrics=True,
return_validated=True,
)
metrics["download_bytes"] += len(img_bytes)
metrics["download_seconds"] += elapsed
if log_downloads:
print(
f"{node_label}: 下载图像 {index}/{total} 完成 | {image.width}×{image.height} | "
f"{_format_transfer_size(len(img_bytes))} | {elapsed:.2f}s | "
f"{_format_transfer_rate(len(img_bytes), elapsed)}"
)
if image is not None and result_url_callback and value.startswith("http"):
result_url_callback(value)
return image
if not candidates:
return ([], metrics) if return_metrics else []
parse_started = time.perf_counter()
results = await asyncio.gather(
*(_load_candidate(index, kind, value) for index, (kind, value) in enumerate(candidates, 1)),
return_exceptions=True,
)
metrics["download_wall_seconds"] = time.perf_counter() - parse_started
if check_interrupt:
check_interrupt()
inline_error = next(
(result for result in results if isinstance(result, _IncompleteInlineImageError)),
None,
)
if inline_error is not None:
for result in results:
if isinstance(result, Image.Image):
result.close()
raise inline_error
images = [result for result in results if isinstance(result, Image.Image)]
if images:
return (images, metrics) if return_metrics else images
first_error = next((result for result in results if isinstance(result, BaseException)), None)
if first_error is not None:
raise RuntimeError(str(first_error)) from None
return ([], metrics) if return_metrics else []
async def _parse_task_images(
task_payload: Dict[str, Any],
session: Any,
api_key: str,
download_semaphore: Optional[asyncio.Semaphore] = None,
check_interrupt: Optional[Callable[[], None]] = None,
result_url_callback: Optional[Callable[[str], None]] = None,
node_label: str = "Nano Banana",
log_downloads: bool = False,
) -> Tuple[List[Image.Image], Dict[str, Any]]:
direct_images, metrics = await _parse_direct_images(
task_payload,
session,
download_semaphore=download_semaphore,
check_interrupt=check_interrupt,
result_url_callback=result_url_callback,
node_label=node_label,
log_downloads=log_downloads,
return_metrics=True,
)
if direct_images:
return direct_images, metrics
client = GeminiAPIClient(api_key=api_key)
last_error = None
async def _download_fallback_image(url: str) -> bytes:
return await _download_image_bytes(
url,
session,
download_semaphore=download_semaphore,
check_interrupt=check_interrupt,
)
for source in _payload_sources(task_payload):
if "candidates" not in source:
continue
try:
images, _ = await client.parse_response_async(
source,
session=session,
image_downloader=_download_fallback_image,
)
if images:
return [_preserve_original_image_info(img, img.copy()) for img in images], metrics
except Exception as e:
last_error = e
if last_error is not None:
raise RuntimeError(str(last_error)) from None
raise RuntimeError(f"任务成功但未找到图片结果: {task_payload}")
def _inline_result_retry_delay(task_id: str, attempt: int) -> float:
base_delay = _INLINE_RESULT_RETRY_DELAYS[attempt]
stable_jitter = (sum(task_id.encode("utf-8")) % 500) / 1000.0
return base_delay + stable_jitter
async def _parse_completed_task_images_with_retry(
task_payload: Dict[str, Any],
session: Any,
base_url: str,
api_key: str,
task_id: str,
node_label: str,
download_semaphore: Optional[asyncio.Semaphore] = None,
check_interrupt: Optional[Callable[[], None]] = None,
result_url_callback: Optional[Callable[[str], None]] = None,
log_downloads: bool = False,
) -> Tuple[Dict[str, Any], Any]:
"""Parse a completed task, re-fetching only when inline image data is corrupt."""
current_payload = task_payload
for attempt in range(len(_INLINE_RESULT_RETRY_DELAYS) + 1):
try:
parsed = await _parse_task_images(
current_payload,
session,
api_key,
download_semaphore=download_semaphore,
check_interrupt=check_interrupt,
result_url_callback=result_url_callback,
node_label=node_label,
log_downloads=log_downloads,
)
return current_payload, parsed
except _IncompleteInlineImageError as exc:
if attempt >= len(_INLINE_RESULT_RETRY_DELAYS):
raise RuntimeError(
f"{node_label}: task_id={task_id} 的内联结果在 "
f"{attempt + 1} 次获取后仍不完整: {exc}"
) from None
delay = _inline_result_retry_delay(task_id, attempt)
print(
f"{node_label}: 内联结果不完整,{delay:.1f}s 后重新查询同一任务 "
f"| task_id={task_id} | retry={attempt + 1}/{len(_INLINE_RESULT_RETRY_DELAYS)}"
)
await _interruptible_sleep(delay, check_interrupt=check_interrupt)
current_payload = await _poll_task(
session,
base_url,
api_key,
task_id,
node_label,
check_interrupt=check_interrupt,
log_body_enabled=False,
progress_callback=None,
log_success=False,
initial_delay=False,
)
raise RuntimeError(f"{node_label}: task_id={task_id} 的内联结果读取失败") # pragma: no cover
async def generate_nano_banana_async(
session: Any,
base_url: str,
api_key: str,
prompt: str,
model: str,
resolution: str,
aspect_ratio: str,
images: Optional[List[Image.Image]] = None,
image_urls: Optional[List[str]] = None,
inline_images: Optional[List[Dict[str, Dict[str, str]]]] = None,
upload_images_to_oss: bool = False,
upload_cache: Optional[Dict[int, Awaitable[Dict[str, Dict[str, str]]]]] = None,
thinking_level: Optional[str] = None,
node_label: str = "Nano Banana",
request_log_enabled: bool = False,
check_interrupt: Optional[Callable[[], None]] = None,
progress_callback: Optional[Callable[[float], None]] = None,
download_semaphore: Optional[asyncio.Semaphore] = None,
result_url_callback: Optional[Callable[[str], None]] = None,
log_task_success: bool = True,
log_downloads: bool = True,
task_completed_callback: Optional[Callable[[str, int, float, List[str]], None]] = None,
resize_mode: str = "不缩放",
google_search: bool = False,
) -> Tuple[List[Image.Image], Dict[str, Any]]:
if check_interrupt:
check_interrupt()
# Retain legacy arguments for call-signature compatibility. Local images
# are encoded into inlineData and are never uploaded for a temporary URL.
del upload_images_to_oss
prepared_inline_images = inline_images
if images and prepared_inline_images is None:
prepared_inline_images = await prepare_nano_banana_inline_images(
images,
model=model,
prompt=prompt,
resolution=resolution,
aspect_ratio=aspect_ratio,
thinking_level=thinking_level,
google_search=google_search,
resize_mode=resize_mode,
inline_cache=upload_cache,
check_interrupt=check_interrupt,
node_label=node_label,
)
body = build_nano_banana_submit_body(
model=model,
prompt=prompt,
resolution=resolution,
aspect_ratio=aspect_ratio,
image_urls=image_urls,
inline_images=prepared_inline_images,
thinking_level=thinking_level,
google_search=google_search,
request_log_enabled=request_log_enabled,
node_label=node_label,
)
validate_nano_banana_request_body(body, node_label=node_label)
task_start = time.time()
if check_interrupt:
check_interrupt()
task_id = await _submit_task(
session,
base_url,
api_key,
body,
node_label,
log_body_enabled=request_log_enabled,
log_success=log_task_success,
)
task_payload = await _poll_task(
session,
base_url,
api_key,
task_id,
node_label,
check_interrupt=check_interrupt,
log_body_enabled=request_log_enabled,
progress_callback=progress_callback,
log_success=log_task_success,
)
task_done = time.time()
parse_start = time.time()
task_payload, parsed_images = await _parse_completed_task_images_with_retry(
task_payload,
session,
base_url,
api_key,
task_id,
node_label,
download_semaphore=download_semaphore,
check_interrupt=check_interrupt,
result_url_callback=None,
log_downloads=log_downloads,
)
# Emit only URLs from the payload that passed result validation. Retried
# inline responses never expose partial/corrupt data to downstream jobs.
if result_url_callback:
for result_url in _extract_result_urls(task_payload):
result_url_callback(result_url)
# Preserve compatibility with integrations/tests that replace the private
# parser and return only the historical image list.
if isinstance(parsed_images, tuple) and len(parsed_images) == 2:
images_list, download_metrics = parsed_images
else:
images_list = parsed_images
download_metrics = {
"download_bytes": 0,
"download_seconds": 0.0,
"download_wall_seconds": 0.0,
"inline_images": 0,
}
parse_done = time.time()
if task_completed_callback:
task_completed_callback(
task_id,
len(images_list),
time.time() - task_start,
_extract_result_urls(task_payload),
)
return images_list, {
"task_id": task_id,
"task_ids": [task_id],
"task_ms": (task_done - task_start) * 1000,
"parse_ms": (parse_done - parse_start) * 1000,
"download_ms": download_metrics["download_wall_seconds"] * 1000,
"download_total_ms": download_metrics["download_seconds"] * 1000,
"download_bytes": download_metrics["download_bytes"],
"inline_images": download_metrics["inline_images"],
"request_bytes": _json_size(body),
}
# Neutral aliases used by other image providers that share O1Key's asynchronous
# task lifecycle. Keep the historical Nano names above for compatibility.
upload_images_to_temp_urls = upload_nano_banana_images_to_temp_urls
submit_async_image_task = _submit_task
poll_async_image_task = _poll_task
parse_completed_async_image_task = _parse_completed_task_images_with_retry
extract_async_image_result_urls = _extract_result_urls