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 "" 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}," candidate = value.strip() if len(candidate) % 4 == 0: try: base64.b64decode(candidate, validate=True) except Exception: pass else: return f"" 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"" 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") 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=" f" | {format_response_body_diagnostics(diagnostics)}" f" | json=not-parsed | Eagleid={eagleid or ''}" ) 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=" f" | {format_response_body_diagnostics(diagnostics)}" f" | json=invalid | Eagleid={eagleid or ''}" ) 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 ''}" ) 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 ''} | " 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 ''} | {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