676 lines
22 KiB
Python
676 lines
22 KiB
Python
import asyncio
|
|
import base64
|
|
import json
|
|
import time
|
|
from io import BytesIO
|
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
|
|
import aiohttp
|
|
from PIL import Image
|
|
|
|
from .http_error import (
|
|
DEFAULT_BACKOFF_FACTOR,
|
|
DEFAULT_BASE_DELAY,
|
|
DEFAULT_MAX_DELAY,
|
|
DEFAULT_MAX_RETRIES,
|
|
RETRYABLE_STATUS_CODES,
|
|
_compute_delay,
|
|
extract_structured_error_message,
|
|
get_friendly_message,
|
|
)
|
|
from ..clients.gemini_client import GeminiAPIClient
|
|
|
|
|
|
_MAX_BODY_BYTES = 20_000_000
|
|
_BODY_TARGET_BYTES = int(_MAX_BODY_BYTES * 0.8)
|
|
_SUBMIT_ENDPOINT = "/async/v1/generateImage"
|
|
_TASK_ENDPOINT = "/async/v1/tasks/{task_id}"
|
|
_POLL_SCHEDULE = [5.0, 20.0]
|
|
_POLL_INTERVAL = 3.0
|
|
_MAX_WAIT_SECONDS = 900.0
|
|
_INTERRUPT_STEP = 0.2
|
|
_RUNNING_PROGRESS_MAX = 0.99
|
|
_POLL_LOG_ENABLED = False
|
|
|
|
_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 _headers(api_key: str) -> Dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
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 _scale_images(images: List[Image.Image], scale: float) -> List[Image.Image]:
|
|
if scale >= 1.0:
|
|
return images
|
|
scaled = []
|
|
for img in images:
|
|
new_w = max(1, int(img.width * scale))
|
|
new_h = max(1, int(img.height * scale))
|
|
scaled.append(img.resize((new_w, new_h), Image.Resampling.LANCZOS))
|
|
return scaled
|
|
|
|
|
|
def _encode_image_data_url(
|
|
image: Image.Image,
|
|
image_format: str,
|
|
quality: Optional[int] = None,
|
|
) -> str:
|
|
buffered = BytesIO()
|
|
working = image
|
|
fmt = image_format.upper()
|
|
save_kwargs = {"format": fmt}
|
|
|
|
if fmt == "JPEG":
|
|
if working.mode != "RGB":
|
|
working = working.convert("RGB")
|
|
save_kwargs.update({"quality": quality or 90, "optimize": True, "subsampling": 2})
|
|
mime_type = "image/jpeg"
|
|
else:
|
|
if working.mode == "RGBA":
|
|
working = working.convert("RGB")
|
|
mime_type = "image/png"
|
|
|
|
working.save(buffered, **save_kwargs)
|
|
encoded = base64.b64encode(buffered.getvalue()).decode("ascii")
|
|
return f"data:{mime_type};base64,{encoded}"
|
|
|
|
|
|
def _encode_image_data_urls(
|
|
images: Sequence[Image.Image],
|
|
image_format: str,
|
|
quality: Optional[int] = None,
|
|
) -> List[str]:
|
|
return [_encode_image_data_url(img, image_format, quality) for img in images]
|
|
|
|
|
|
def _fit_image_data_urls_to_body_limit(
|
|
images: Sequence[Image.Image],
|
|
build_body: Callable[[List[str]], Dict[str, Any]],
|
|
) -> Tuple[List[str], List[Image.Image], str, int]:
|
|
working_images = list(images)
|
|
image_urls = _encode_image_data_urls(working_images, "PNG")
|
|
body_size = _json_size(build_body(image_urls))
|
|
if body_size <= _BODY_TARGET_BYTES:
|
|
return image_urls, working_images, "PNG", body_size
|
|
|
|
for _ in range(10):
|
|
if body_size <= _BODY_TARGET_BYTES:
|
|
break
|
|
ratio = _BODY_TARGET_BYTES / max(body_size, 1)
|
|
scale = min(0.98, ratio ** 0.5)
|
|
working_images = _scale_images(working_images, scale)
|
|
image_urls = _encode_image_data_urls(working_images, "PNG")
|
|
body_size = _json_size(build_body(image_urls))
|
|
|
|
return image_urls, working_images, "PNG", body_size
|
|
|
|
|
|
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) and len(item) > max_len:
|
|
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) and value.startswith("data:image") and len(value) > max_len:
|
|
return f"<data image url, {len(value)} chars>"
|
|
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:
|
|
print(f"{label}\n{text_or_body}")
|
|
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,
|
|
enable_grounding: bool = False,
|
|
thinking_level: Optional[str] = None,
|
|
request_log_enabled: bool = False,
|
|
node_label: str = "Nano Banana",
|
|
) -> Dict[str, Any]:
|
|
def _make_body(image_urls: List[str]) -> Dict[str, Any]:
|
|
body: Dict[str, Any] = {
|
|
"model": model,
|
|
"prompt": prompt,
|
|
"size": resolution,
|
|
}
|
|
if aspect_ratio and aspect_ratio != "智能":
|
|
body["aspect_ratio"] = aspect_ratio
|
|
if image_urls:
|
|
body["images"] = image_urls
|
|
if enable_grounding:
|
|
body["google_search"] = True
|
|
if thinking_level:
|
|
body["thinking_level"] = thinking_level
|
|
return body
|
|
|
|
working_images = list(images or [])
|
|
image_urls: List[str] = []
|
|
|
|
if working_images:
|
|
image_urls, working_images, _, _ = _fit_image_data_urls_to_body_limit(
|
|
working_images,
|
|
_make_body,
|
|
)
|
|
|
|
body = _make_body(image_urls)
|
|
body_size = _json_size(body)
|
|
|
|
if working_images and body_size > _MAX_BODY_BYTES:
|
|
raise ValueError(
|
|
f"Request body exceeds the 20MB limit after compression "
|
|
f"({body_size / 1_000_000:.2f}MB). Reduce reference image count, "
|
|
"image complexity, or prompt length."
|
|
)
|
|
if not working_images and body_size > _MAX_BODY_BYTES:
|
|
raise ValueError(
|
|
f"Request body exceeds the 20MB limit ({body_size / 1_000_000:.2f}MB). "
|
|
"Shorten the prompt or system instructions."
|
|
)
|
|
|
|
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
|
|
|
|
|
|
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 _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 "未知错误"
|
|
|
|
|
|
async def _submit_task(
|
|
session: aiohttp.ClientSession,
|
|
base_url: str,
|
|
api_key: str,
|
|
body: Dict[str, Any],
|
|
node_label: str,
|
|
log_body_enabled: bool = False,
|
|
) -> str:
|
|
url = f"{base_url}{_SUBMIT_ENDPOINT}"
|
|
timeout = aiohttp.ClientTimeout(total=120, connect=30, sock_read=120)
|
|
last_status = None
|
|
last_text = ""
|
|
|
|
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
|
try:
|
|
async with session.post(
|
|
url,
|
|
headers=_headers(api_key),
|
|
data=_json_dumps(body).encode("utf-8"),
|
|
timeout=timeout,
|
|
) as resp:
|
|
text = await resp.text()
|
|
if log_body_enabled:
|
|
_log_body(f"[{node_label} 异步提交响应] HTTP {resp.status}", text)
|
|
if resp.status not in (200, 201, 202):
|
|
last_status = resp.status
|
|
last_text = text
|
|
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
|
|
friendly = get_friendly_message(resp.status)
|
|
delay = _compute_delay(
|
|
attempt,
|
|
DEFAULT_BASE_DELAY,
|
|
DEFAULT_MAX_DELAY,
|
|
DEFAULT_BACKOFF_FACTOR,
|
|
)
|
|
print(f"{node_label}: {friendly} {delay:.1f}s 后重试提交 ({attempt + 1}/{DEFAULT_MAX_RETRIES})...")
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
raise RuntimeError(get_friendly_message(resp.status, text))
|
|
|
|
try:
|
|
data = json.loads(text)
|
|
except Exception:
|
|
raise RuntimeError(f"提交响应 JSON 解析失败: {text[:500]}") from None
|
|
task_id = _extract_task_id(data)
|
|
status = _extract_status(data) or "SUBMITTED"
|
|
print(f"{node_label}: 异步任务已提交 | task_id={task_id} | status={status}")
|
|
return task_id
|
|
except (
|
|
aiohttp.ClientConnectorError,
|
|
aiohttp.ClientOSError,
|
|
aiohttp.ServerDisconnectedError,
|
|
asyncio.TimeoutError,
|
|
) as e:
|
|
last_text = str(e)
|
|
if attempt < DEFAULT_MAX_RETRIES:
|
|
delay = _compute_delay(
|
|
attempt,
|
|
DEFAULT_BASE_DELAY,
|
|
DEFAULT_MAX_DELAY,
|
|
DEFAULT_BACKOFF_FACTOR,
|
|
)
|
|
print(f"{node_label}: 网络连接失败,{delay:.1f}s 后重试提交 ({attempt + 1}/{DEFAULT_MAX_RETRIES})...")
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
raise RuntimeError(
|
|
f"网络连接失败,无法连接 {url}: {str(e)}。请切换节点里的网络线路,或检查 VPN/代理/防火墙。"
|
|
) from None
|
|
|
|
raise RuntimeError(get_friendly_message(last_status or 0, last_text))
|
|
|
|
|
|
async def _poll_task(
|
|
session: aiohttp.ClientSession,
|
|
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,
|
|
) -> 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
|
|
|
|
while True:
|
|
if check_interrupt:
|
|
check_interrupt()
|
|
|
|
if poll_count < len(_POLL_SCHEDULE):
|
|
next_poll_at = start_time + _POLL_SCHEDULE[poll_count]
|
|
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
|
|
async with session.get(url, headers=_headers(api_key)) as resp:
|
|
text = await resp.text()
|
|
if log_body_enabled:
|
|
_log_body(f"[{node_label} 任务查询响应 #{poll_count}] HTTP {resp.status}", text)
|
|
if resp.status != 200:
|
|
raise RuntimeError(get_friendly_message(resp.status, text))
|
|
try:
|
|
payload = json.loads(text)
|
|
except Exception:
|
|
raise RuntimeError(f"任务查询响应 JSON 解析失败: {text[:500]}") from None
|
|
|
|
status = _extract_status(payload) or "UNKNOWN"
|
|
normalized = status.lower()
|
|
progress = _extract_progress(payload)
|
|
is_failure = _is_failure_status(normalized)
|
|
if _POLL_LOG_ENABLED:
|
|
progress_text = ""
|
|
if progress is not None and not is_failure:
|
|
displayed_progress = 1.0 if normalized in _SUCCESS_STATUSES else min(progress, _RUNNING_PROGRESS_MAX)
|
|
progress_text = f" | progress={displayed_progress * 100:.0f}%"
|
|
print(f"{node_label}: 查询任务 #{poll_count} | task_id={task_id} | status={status}{progress_text}")
|
|
|
|
if normalized in _SUCCESS_STATUSES:
|
|
if progress_callback:
|
|
progress_callback(1.0)
|
|
return payload
|
|
if is_failure:
|
|
raise RuntimeError(f"任务失败: {_extract_error_message(payload)}")
|
|
if normalized not in _RUNNING_STATUSES:
|
|
raise RuntimeError(f"未知任务状态 {status}: {payload}")
|
|
if progress_callback and progress is not None:
|
|
progress_callback(min(progress, _RUNNING_PROGRESS_MAX))
|
|
|
|
|
|
async def _image_from_url_or_data(
|
|
value: str,
|
|
session: aiohttp.ClientSession,
|
|
) -> Optional[Image.Image]:
|
|
if not value:
|
|
return None
|
|
if value.startswith("data:image"):
|
|
try:
|
|
_, b64_data = value.split(",", 1)
|
|
return Image.open(BytesIO(base64.b64decode(b64_data))).convert("RGB")
|
|
except Exception as e:
|
|
raise RuntimeError(f"data URL 图片解码失败: {e}") from None
|
|
if value.startswith("http"):
|
|
async with session.get(value, allow_redirects=True) as resp:
|
|
if resp.status != 200:
|
|
raise RuntimeError(f"图片下载失败 ({resp.status}): {value}")
|
|
img_bytes = await resp.read()
|
|
return Image.open(BytesIO(img_bytes)).convert("RGB")
|
|
return None
|
|
|
|
|
|
async def _parse_direct_images(
|
|
payload: Dict[str, Any],
|
|
session: aiohttp.ClientSession,
|
|
) -> List[Image.Image]:
|
|
images: List[Image.Image] = []
|
|
|
|
async def _try_item(item: Any) -> None:
|
|
if isinstance(item, str):
|
|
img = await _image_from_url_or_data(item, session)
|
|
if img:
|
|
images.append(img)
|
|
return
|
|
if not isinstance(item, dict):
|
|
return
|
|
|
|
for key in ("url", "image_url", "result_url", "download_url"):
|
|
img = await _image_from_url_or_data(str(item.get(key) or ""), session)
|
|
if img:
|
|
images.append(img)
|
|
return
|
|
|
|
b64_data = item.get("b64_json") or item.get("base64") or item.get("image_base64")
|
|
if b64_data:
|
|
images.append(Image.open(BytesIO(base64.b64decode(str(b64_data)))).convert("RGB"))
|
|
return
|
|
|
|
for inline_key in ("inline_data", "inlineData"):
|
|
inline = item.get(inline_key)
|
|
if isinstance(inline, dict) and inline.get("data"):
|
|
images.append(Image.open(BytesIO(base64.b64decode(str(inline["data"])))).convert("RGB"))
|
|
return
|
|
|
|
for source in _payload_sources(payload):
|
|
for key in ("image_url", "result_url", "url", "download_url"):
|
|
img = await _image_from_url_or_data(str(source.get(key) or ""), session)
|
|
if img:
|
|
images.append(img)
|
|
|
|
for key in ("images", "output_images", "outputs"):
|
|
value = source.get(key)
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
await _try_item(item)
|
|
elif value:
|
|
await _try_item(value)
|
|
|
|
return images
|
|
|
|
|
|
async def _parse_task_images(
|
|
task_payload: Dict[str, Any],
|
|
session: aiohttp.ClientSession,
|
|
api_key: str,
|
|
) -> List[Image.Image]:
|
|
direct_images = await _parse_direct_images(task_payload, session)
|
|
if direct_images:
|
|
return direct_images
|
|
|
|
client = GeminiAPIClient(api_key=api_key)
|
|
last_error = None
|
|
for source in _payload_sources(task_payload):
|
|
if "candidates" not in source:
|
|
continue
|
|
try:
|
|
images, _ = await client.parse_response_async(source, session=session)
|
|
if images:
|
|
return [img.convert("RGB") for img in images]
|
|
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}")
|
|
|
|
|
|
async def generate_nano_banana_async(
|
|
session: aiohttp.ClientSession,
|
|
base_url: str,
|
|
api_key: str,
|
|
prompt: str,
|
|
model: str,
|
|
resolution: str,
|
|
aspect_ratio: str,
|
|
images: Optional[List[Image.Image]] = None,
|
|
enable_grounding: bool = False,
|
|
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,
|
|
) -> Tuple[List[Image.Image], Dict[str, Any]]:
|
|
if check_interrupt:
|
|
check_interrupt()
|
|
|
|
body = build_nano_banana_submit_body(
|
|
model=model,
|
|
prompt=prompt,
|
|
resolution=resolution,
|
|
aspect_ratio=aspect_ratio,
|
|
images=images,
|
|
enable_grounding=enable_grounding,
|
|
thinking_level=thinking_level,
|
|
request_log_enabled=request_log_enabled,
|
|
node_label=node_label,
|
|
)
|
|
|
|
task_start = time.time()
|
|
task_id = await _submit_task(
|
|
session,
|
|
base_url,
|
|
api_key,
|
|
body,
|
|
node_label,
|
|
log_body_enabled=request_log_enabled,
|
|
)
|
|
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,
|
|
)
|
|
task_done = time.time()
|
|
|
|
parse_start = time.time()
|
|
images_list = await _parse_task_images(task_payload, session, api_key)
|
|
parse_done = time.time()
|
|
|
|
return images_list, {
|
|
"task_id": task_id,
|
|
"task_ms": (task_done - task_start) * 1000,
|
|
"parse_ms": (parse_done - parse_start) * 1000,
|
|
"request_bytes": _json_size(body),
|
|
}
|