codex-image-generation 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,627 @@
1
+ """Responses image tools with bounded local processing and partial-result recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import binascii
8
+ import inspect
9
+ import math
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Mapping
14
+ from urllib.parse import urlsplit
15
+
16
+ from openai import APIConnectionError, APIStatusError, APITimeoutError, AsyncOpenAI, OpenAIError, omit
17
+
18
+ from .errors import ImageToolError
19
+ from .storage import (
20
+ MAX_INPUT_BYTES, MAX_OUTPUT_BYTES, GeneratedImage, ImageInfo, PreparedImage,
21
+ inspect_image, prepare_save, read_input_image, resolve_format, resolve_project, save_images,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from .config import ResolvedConfig
26
+
27
+ # Application resource budgets, not assertions about a model's capabilities.
28
+ MAX_INPUT_IMAGES = 16
29
+ MAX_INPUT_TOTAL_BYTES = 100_000_000
30
+ MAX_OUTPUT_IMAGES = 16
31
+ MAX_OUTPUT_TOTAL_BYTES = 200 * 1024 * 1024
32
+ MAX_PARTIAL_IMAGES = 12
33
+ MAX_PARTIAL_TOTAL_BYTES = 32 * 1024 * 1024
34
+ MAX_TEXT_CHARS = 32_768
35
+ ProgressCallback = Callable[[dict[str, Any]], Awaitable[None]]
36
+
37
+
38
+ @dataclass
39
+ class GenerationResult:
40
+ images: list[GeneratedImage] = field(default_factory=list)
41
+ text: str = ""
42
+ response_id: str | None = None
43
+ request_id: str | None = None
44
+ usage: dict[str, Any] | None = None
45
+ warnings: list[str] = field(default_factory=list)
46
+ errors: list[dict[str, Any]] = field(default_factory=list)
47
+ status: str = "error"
48
+ partials: list[PreparedImage] = field(default_factory=list)
49
+ model: str | None = None
50
+
51
+
52
+ def _get(value: Any, key: str, default: Any = None) -> Any:
53
+ return value.get(key, default) if isinstance(value, Mapping) else getattr(value, key, default)
54
+
55
+
56
+ def _choice(value: str, allowed: set[str], name: str) -> str:
57
+ if not isinstance(value, str) or value not in allowed:
58
+ raise ImageToolError(f"{name} 必须是以下值之一:{', '.join(sorted(allowed))}。")
59
+ return value
60
+
61
+
62
+ def _nonempty(value: Any, name: str) -> str:
63
+ if not isinstance(value, str) or not value.strip() or any(ord(char) < 32 for char in value):
64
+ raise ImageToolError(f"{name} 必须是非空的单行字符串。")
65
+ return value
66
+
67
+
68
+ def validate_options(
69
+ *, output_format: str, size: str | None = None, quality: str | None = None,
70
+ background: str | None = None, output_compression: int | None = None,
71
+ action: str | None = None, input_fidelity: str | None = None,
72
+ moderation: str | None = None, partial_images: int = 0,
73
+ ) -> dict[str, Any]:
74
+ """Validate syntax and incompatible options without guessing model capability."""
75
+ tool: dict[str, Any] = {"type": "image_generation", "output_format": output_format}
76
+ choices = {
77
+ "quality": (quality, {"auto", "low", "medium", "high", "xhigh", "max"}),
78
+ "background": (background, {"auto", "opaque", "transparent"}),
79
+ "action": (action, {"auto", "generate", "edit"}),
80
+ "input_fidelity": (input_fidelity, {"low", "high"}),
81
+ "moderation": (moderation, {"auto", "low"}),
82
+ }
83
+ for name, (value, allowed) in choices.items():
84
+ if value is not None:
85
+ tool[name] = _choice(value, allowed, name)
86
+ if size is not None:
87
+ if not isinstance(size, str) or len(size) > 64:
88
+ raise ImageToolError("size 必须是 auto 或正整数 WIDTHxHEIGHT;本地参数长度限制为 64 字符。")
89
+ size = size.strip().lower().replace("×", "x")
90
+ if size != "auto" and not re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", size):
91
+ raise ImageToolError("size 必须是 auto 或正整数 WIDTHxHEIGHT,例如 1536x1024。")
92
+ tool["size"] = size
93
+ if background == "transparent" and output_format == "jpeg":
94
+ raise ImageToolError("JPEG 不支持透明背景,请使用 PNG 或 WebP。")
95
+ if output_compression is not None:
96
+ if type(output_compression) is not int or not 0 <= output_compression <= 100:
97
+ raise ImageToolError("output_compression 必须是 0 到 100 的整数。")
98
+ if output_format not in {"jpeg", "webp"}:
99
+ raise ImageToolError("output_compression 仅用于 JPEG/WebP;PNG 请省略此参数。")
100
+ tool["output_compression"] = output_compression
101
+ if type(partial_images) is not int or not 0 <= partial_images <= 3:
102
+ raise ImageToolError("partial_images 必须是 0 到 3 的整数。")
103
+ if partial_images:
104
+ tool["partial_images"] = partial_images
105
+ return tool
106
+
107
+
108
+ async def _header_auth_key() -> str:
109
+ """Use the SDK's public key-provider contract for explicit header authentication."""
110
+ return ""
111
+
112
+
113
+ def _request_headers(config: ResolvedConfig) -> dict[str, Any]:
114
+ headers: dict[str, Any] = {}
115
+ for line in os.environ.get("OPENAI_CUSTOM_HEADERS", "").splitlines():
116
+ name, separator, _ = line.partition(":")
117
+ name = name.strip().lower()
118
+ if separator and re.fullmatch(r"[!#$%&'*+.^_`|~0-9a-z-]+", name):
119
+ headers[name] = omit
120
+ if config.provider != "openai":
121
+ headers.update({"openai-organization": omit, "openai-project": omit})
122
+ headers.update({name.lower(): value for name, value in config.headers.items()})
123
+ if config.api_key:
124
+ headers["authorization"] = f"Bearer {config.api_key}"
125
+ elif not isinstance(headers.get("authorization"), str):
126
+ headers["authorization"] = omit
127
+ return headers
128
+
129
+
130
+ async def _settle(task: asyncio.Task[Any]) -> Any:
131
+ """Wait for a started critical operation even after a repeated cancellation."""
132
+ while True:
133
+ try:
134
+ return await asyncio.shield(task)
135
+ except asyncio.CancelledError:
136
+ if task.done():
137
+ return task.result()
138
+
139
+
140
+ async def _offload(function: Callable[..., Any], *args: Any,
141
+ on_cancel: Callable[[Any], Any] | None = None, **kwargs: Any) -> Any:
142
+ task = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
143
+ try:
144
+ return await asyncio.shield(task)
145
+ except asyncio.CancelledError:
146
+ # A thread cannot be killed safely. Collect its result and finish critical
147
+ # cleanup before propagating cancellation, so reservations never outlive it.
148
+ try:
149
+ result = await _settle(task)
150
+ if on_cancel is not None:
151
+ await _settle(asyncio.create_task(asyncio.to_thread(on_cancel, result)))
152
+ except Exception:
153
+ pass
154
+ raise
155
+
156
+
157
+ def _decode_base64(value: Any, byte_limit: int, label: str) -> bytes:
158
+ if not isinstance(value, str) or not value or len(value) > ((byte_limit + 2) // 3) * 4:
159
+ raise ImageToolError(f"{label} 为空或超过本地字节限制。")
160
+ try:
161
+ data = base64.b64decode(value, validate=True)
162
+ except (binascii.Error, ValueError):
163
+ raise ImageToolError(f"{label} 不是有效的 Base64 数据。") from None
164
+ if not data or len(data) >= byte_limit:
165
+ raise ImageToolError(f"{label} 为空或超过本地字节限制。")
166
+ return data
167
+
168
+
169
+ def _source(source: Any, cwd: Any, *, is_mask: bool = False) -> tuple[dict[str, Any], ImageInfo | None, int]:
170
+ if not isinstance(source, dict):
171
+ raise ImageToolError("每个图像来源必须是包含 path、image_url 或 file_id 之一的对象。")
172
+ allowed = {"path", "image_url", "file_id"} | (set() if is_mask else {"detail"})
173
+ sources = set(source) & {"path", "image_url", "file_id"}
174
+ if len(sources) != 1 or set(source) - allowed:
175
+ raise ImageToolError("每个图像必须且只能指定 path、image_url、file_id 之一;参考图可另带 detail。")
176
+ key = next(iter(sources))
177
+ value = _nonempty(source[key], key)
178
+ info = None
179
+ byte_count = 0
180
+ if key == "path":
181
+ data, info = read_input_image(cwd, value)
182
+ result = {"image_url": f"data:{info.mime_type};base64,{base64.b64encode(data).decode('ascii')}"}
183
+ byte_count = len(data)
184
+ elif key == "file_id":
185
+ result = {"file_id": value}
186
+ elif value.lower().startswith("data:"):
187
+ header, comma, payload = value.partition(",")
188
+ match = re.fullmatch(r"data:(image/(?:png|jpeg|webp));base64", header, re.IGNORECASE)
189
+ if not comma or not match:
190
+ raise ImageToolError("本地支持的 data URL 必须是 PNG/JPEG/WebP 的 base64 图像。")
191
+ data = _decode_base64(payload, MAX_INPUT_BYTES, "输入图片")
192
+ info = inspect_image(data, input_image=True)
193
+ if info.mime_type != match[1].lower():
194
+ raise ImageToolError("data URL 的 MIME 类型与实际图片格式不一致。")
195
+ result, byte_count = {"image_url": value}, len(data)
196
+ else:
197
+ try:
198
+ url = urlsplit(value)
199
+ valid = url.scheme.lower() in {"http", "https"} and bool(url.hostname)
200
+ _ = url.port
201
+ except ValueError:
202
+ valid = False
203
+ if not valid:
204
+ raise ImageToolError("image_url 必须是完整 HTTP(S) URL 或图像 data URL。")
205
+ # Do not fetch remote images locally. The configured provider resolves them.
206
+ result = {"image_url": value}
207
+ if not is_mask and "detail" in source:
208
+ result["detail"] = _choice(source["detail"], {"auto", "low", "high", "original"}, "detail")
209
+ return result, info, byte_count
210
+
211
+
212
+ def _prepare_input(prompt: str, cwd: str, tool: dict[str, Any], input_images: list[dict[str, Any]] | None,
213
+ mask: dict[str, Any] | None, previous_response_id: str | None,
214
+ previous_image_ids: list[str] | None) -> str | list[dict[str, Any]]:
215
+ project = resolve_project(cwd)
216
+ images = [] if input_images is None else input_images
217
+ previous_ids = [] if previous_image_ids is None else previous_image_ids
218
+ if not isinstance(images, list) or not isinstance(previous_ids, list):
219
+ raise ImageToolError("input_images 和 previous_image_ids 必须是列表。")
220
+ if len(images) + len(previous_ids) > MAX_INPUT_IMAGES:
221
+ raise ImageToolError(f"超过本地单次输入限制:最多 {MAX_INPUT_IMAGES} 张图像引用。")
222
+ for image_id in previous_ids:
223
+ _nonempty(image_id, "previous_image_ids")
224
+ has_reference = bool(images or previous_ids or previous_response_id)
225
+ if tool.get("action") == "edit" and not has_reference:
226
+ raise ImageToolError("编辑需要 input_images、previous_image_ids 或 previous_response_id。")
227
+ if mask is not None and (not has_reference or tool.get("action") == "generate"):
228
+ raise ImageToolError("蒙版需要图像引用,并且 action 不能为 generate。")
229
+ if "input_fidelity" in tool and not has_reference:
230
+ raise ImageToolError("input_fidelity 需要参考图片或前一轮图像上下文。")
231
+ content: list[dict[str, Any]] = [{"type": "input_text", "text": prompt}]
232
+ first_info = None
233
+ total = 0
234
+ for index, source in enumerate(images):
235
+ converted, info, size = _source(source, project)
236
+ total += size
237
+ if total > MAX_INPUT_TOTAL_BYTES:
238
+ raise ImageToolError("输入的本地/data 图片超过本地总字节预算 100 MB。")
239
+ if index == 0:
240
+ first_info = info
241
+ content.append({"type": "input_image", **converted})
242
+ if mask is not None:
243
+ converted, info, size = _source(mask, project, is_mask=True)
244
+ if total + size > MAX_INPUT_TOTAL_BYTES:
245
+ raise ImageToolError("图片与蒙版超过本地总字节预算 100 MB。")
246
+ if info is not None:
247
+ if not info.has_alpha:
248
+ raise ImageToolError("本地/data 蒙版必须包含 alpha 通道。")
249
+ if first_info is not None and (info.format, info.width, info.height) != (first_info.format, first_info.width, first_info.height):
250
+ raise ImageToolError("蒙版必须与第一张参考图具有相同格式和像素尺寸。")
251
+ tool["input_image_mask"] = converted
252
+ if not images and not previous_ids:
253
+ return prompt
254
+ return ([{"type": "image_generation_call", "id": image_id} for image_id in previous_ids]
255
+ + [{"role": "user", "content": content}])
256
+
257
+
258
+ def _numeric_usage(value: Any, depth: int = 0, *, redact: Callable[[Any], str] | None = None) -> Any:
259
+ if depth > 6:
260
+ return None
261
+ if hasattr(value, "model_dump"):
262
+ value = value.model_dump()
263
+ if isinstance(value, dict):
264
+ return {key: _numeric_usage(item, depth + 1, redact=redact) for key, item in list(value.items())[:64]
265
+ if isinstance(key, str) and re.fullmatch(r"[A-Za-z0-9_]{1,80}", key)
266
+ and (redact is None or redact(key) == key)}
267
+ if value is None or type(value) in {int, bool}:
268
+ return value
269
+ if isinstance(value, float) and math.isfinite(value):
270
+ return value
271
+ return None
272
+
273
+
274
+ class _State:
275
+ def __init__(self, config: ResolvedConfig):
276
+ self.result = GenerationResult()
277
+ self.secrets = [value for value in [config.api_key, *config.headers.values(), *config.query_params.values()]
278
+ if isinstance(value, str) and value]
279
+ self.secrets = sorted(set(self.secrets), key=len, reverse=True)
280
+ self.redaction_padding = max((len(value) for value in self.secrets), default=0)
281
+ self.items: dict[str, Any] = {}
282
+ self.encoded_bytes = 0
283
+ self.partial_bytes = 0
284
+ self.partial_keys: set[tuple[str | None, int]] = set()
285
+ self.terminal = False
286
+ self.prepared: list[PreparedImage] = []
287
+ self.decoded = False
288
+ self.saved = False
289
+
290
+ def text(self, value: Any) -> str:
291
+ if not isinstance(value, str):
292
+ return ""
293
+ # Include enough look-ahead to redact a secret crossing the text cutoff.
294
+ value = value[:MAX_TEXT_CHARS + self.redaction_padding]
295
+ for secret in self.secrets:
296
+ if len(secret) >= 6:
297
+ value = value.replace(secret, "[redacted]")
298
+ elif value == secret:
299
+ return "[redacted]"
300
+ return value[:MAX_TEXT_CHARS]
301
+
302
+ def identifier(self, value: Any) -> str | None:
303
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,511}", value):
304
+ return None
305
+ return value if self.text(value) == value else None
306
+
307
+ def warn(self, message: str) -> None:
308
+ if len(self.result.warnings) < 32 and message not in self.result.warnings:
309
+ self.result.warnings.append(message)
310
+
311
+ def error(self, code: str, message: str, **metadata: Any) -> None:
312
+ if len(self.result.errors) < 32:
313
+ self.result.errors.append({"code": code, "message": message, **metadata})
314
+
315
+ def remember(self, item: Any, index: int | None = None) -> None:
316
+ if _get(item, "type") != "image_generation_call":
317
+ return
318
+ raw_id = _get(item, "id")
319
+ key = f"id:{raw_id}" if isinstance(raw_id, str) and raw_id else f"index:{index if index is not None else len(self.items)}"
320
+ old = self.items.get(key)
321
+ if old is not None and _get(old, "status") in {None, "completed"} and _get(old, "result"):
322
+ return
323
+ encoded = _get(item, "result")
324
+ length = len(encoded) if isinstance(encoded, str) else 0
325
+ old_encoded = _get(old, "result")
326
+ old_length = len(old_encoded) if isinstance(old_encoded, str) else 0
327
+ if old is None and len(self.items) >= MAX_OUTPUT_IMAGES:
328
+ self.error("local_output_limit", "上游图片数量超过本地限制,其余图片未保留。")
329
+ return
330
+ if self.encoded_bytes - old_length + length > ((MAX_OUTPUT_TOTAL_BYTES + 2) // 3) * 4:
331
+ self.error("local_output_limit", "上游图片总数据超过本地 200 MiB 预算,其余图片未保留。")
332
+ return
333
+ self.items[key] = item
334
+ self.encoded_bytes += length - old_length
335
+
336
+ def response(self, response: Any, *, terminal: bool = False) -> None:
337
+ self.result.response_id = self.identifier(_get(response, "id")) or self.result.response_id
338
+ self.result.request_id = self.identifier(_get(response, "_request_id")) or self.result.request_id
339
+ actual_model = self.identifier(_get(response, "model"))
340
+ if actual_model:
341
+ self.result.model = actual_model
342
+ usage = _numeric_usage(_get(response, "usage"), redact=self.text)
343
+ if isinstance(usage, dict):
344
+ self.result.usage = usage
345
+ try:
346
+ text = self.text(_get(response, "output_text"))
347
+ except Exception:
348
+ text = ""
349
+ items = _get(response, "output")
350
+ if not isinstance(items, (list, tuple)):
351
+ items = []
352
+ text_parts = []
353
+ remaining = MAX_TEXT_CHARS + self.redaction_padding
354
+ for index, item in enumerate(items):
355
+ self.remember(item, index)
356
+ if _get(item, "type") == "message":
357
+ parts = _get(item, "content", [])
358
+ if not isinstance(parts, (list, tuple)):
359
+ continue
360
+ for part in parts:
361
+ if _get(part, "type") == "output_text":
362
+ part_text = _get(part, "text")
363
+ if isinstance(part_text, str) and remaining > 0:
364
+ fragment = part_text[:remaining]
365
+ text_parts.append(fragment)
366
+ remaining -= len(fragment)
367
+ # A credential can span adjacent output_text blocks.
368
+ self.result.text = text or self.text("".join(text_parts)) or self.result.text
369
+ if terminal:
370
+ self.terminal = True
371
+ status = _get(response, "status")
372
+ if status not in {None, "completed"}:
373
+ self.error("response_incomplete", "上游响应未完整完成;已完成且有效的图片仍会保存。")
374
+
375
+ def accept_decoded(self, value: tuple[list[PreparedImage], list[dict[str, Any]], list[str]]) -> None:
376
+ self.prepared, errors, warnings = value
377
+ self.result.errors.extend(errors[:max(0, 32 - len(self.result.errors))])
378
+ for warning in warnings:
379
+ self.warn(warning)
380
+ self.decoded = True
381
+
382
+ def accept_saved(self, value: Any) -> None:
383
+ self.result.images.extend(value.images)
384
+ self.result.errors.extend(value.errors[:max(0, 32 - len(self.result.errors))])
385
+ for warning in getattr(value, "warnings", []):
386
+ self.warn(warning)
387
+ self.saved = True
388
+
389
+
390
+ def _decode_items(state: _State, tool: dict[str, Any]) -> tuple[list[PreparedImage], list[dict[str, Any]], list[str]]:
391
+ prepared, errors, warnings = [], [], []
392
+ total = 0
393
+ requested_size = tool.get("size")
394
+ for index, item in enumerate(state.items.values()):
395
+ call_id = state.identifier(_get(item, "id"))
396
+ if _get(item, "status") not in {None, "completed"} or _get(item, "error") is not None:
397
+ errors.append({"code": "image_generation_failed", "message": "该图片工具调用失败或未完成。", "call_id": call_id, "index": index})
398
+ continue
399
+ try:
400
+ data = _decode_base64(_get(item, "result"), MAX_OUTPUT_BYTES, "返回图片")
401
+ total += len(data)
402
+ if total > MAX_OUTPUT_TOTAL_BYTES:
403
+ raise ImageToolError("返回图片超过本地总字节预算 200 MiB。")
404
+ info = inspect_image(data)
405
+ except ImageToolError as exc:
406
+ errors.append({"code": "invalid_image", "message": str(exc), "call_id": call_id, "index": index})
407
+ continue
408
+ metadata = {"output_format": info.format, "size": f"{info.width}x{info.height}"}
409
+ for name, allowed in {"action": {"auto", "generate", "edit"}, "quality": {"auto", "low", "medium", "high", "xhigh", "max"},
410
+ "background": {"auto", "opaque", "transparent"}}.items():
411
+ value = _get(item, name)
412
+ if isinstance(value, str) and value in allowed:
413
+ metadata[name] = value
414
+ prepared.append(PreparedImage(data, info, state.text(_get(item, "revised_prompt")) or None, call_id, metadata))
415
+ if requested_size and requested_size != "auto" and requested_size != metadata["size"]:
416
+ warnings.append(f"第 {index + 1} 张图片实际尺寸为 {metadata['size']},与请求的 {requested_size} 不同。")
417
+ if tool.get("background") == "transparent" and not info.has_transparency:
418
+ warnings.append(f"第 {index + 1} 张图片没有透明像素,与透明背景请求不一致。")
419
+ return prepared, errors, warnings
420
+
421
+
422
+ def _decode_partial(event: Any, state: _State) -> PreparedImage:
423
+ data = _decode_base64(_get(event, "partial_image_b64"), min(MAX_OUTPUT_BYTES, MAX_PARTIAL_TOTAL_BYTES), "中间图片")
424
+ info = inspect_image(data)
425
+ item_id = state.identifier(_get(event, "item_id"))
426
+ return PreparedImage(data, info, call_id=item_id, metadata={
427
+ "partial_index": _get(event, "partial_image_index"), "item_id": item_id,
428
+ "output_format": info.format, "size": f"{info.width}x{info.height}", "is_partial": True,
429
+ })
430
+
431
+
432
+ async def _notify(callback: ProgressCallback | None, event: dict[str, Any], state: _State) -> None:
433
+ if callback is not None:
434
+ try:
435
+ await callback(event)
436
+ except Exception:
437
+ state.warn("进度通知不可用,生成与保存继续进行。")
438
+
439
+
440
+ async def _consume_stream(stream: Any, state: _State, on_progress: ProgressCallback | None) -> None:
441
+ headers = _get(_get(stream, "response"), "headers", {})
442
+ if hasattr(headers, "get"):
443
+ state.result.request_id = state.identifier(headers.get("x-request-id"))
444
+ try:
445
+ async for event in stream:
446
+ event_type = _get(event, "type", "")
447
+ if event_type in {"response.created", "response.in_progress"}:
448
+ state.response(_get(event, "response"))
449
+ await _notify(on_progress, {"stage": event_type.rsplit(".", 1)[-1]}, state)
450
+ elif event_type == "response.output_item.done":
451
+ item = _get(event, "item")
452
+ state.remember(item, _get(event, "output_index"))
453
+ if _get(item, "type") == "image_generation_call":
454
+ await _notify(on_progress, {"stage": "image_generated", "item_id": state.identifier(_get(item, "id"))}, state)
455
+ elif event_type == "response.image_generation_call.partial_image":
456
+ index = _get(event, "partial_image_index")
457
+ item_id = state.identifier(_get(event, "item_id"))
458
+ key = (item_id, index)
459
+ if type(index) is not int or index < 0:
460
+ state.warn("忽略索引无效的中间图片事件。")
461
+ continue
462
+ if key in state.partial_keys:
463
+ continue
464
+ if len(state.result.partials) >= MAX_PARTIAL_IMAGES:
465
+ state.warn("中间图片超过本地数量限制,后续仅保留最终图片。")
466
+ continue
467
+ encoded = _get(event, "partial_image_b64")
468
+ remaining = MAX_PARTIAL_TOTAL_BYTES - state.partial_bytes
469
+ if not isinstance(encoded, str) or len(encoded) > ((remaining + 2) // 3) * 4:
470
+ state.warn("中间图片超过本地 32 MiB 总预算,后续仅保留最终图片。")
471
+ continue
472
+ try:
473
+ partial = await _offload(_decode_partial, event, state)
474
+ except ImageToolError:
475
+ state.warn("忽略无法解码的中间图片,最终图片仍会独立校验。")
476
+ continue
477
+ if state.partial_bytes + len(partial.data) > MAX_PARTIAL_TOTAL_BYTES:
478
+ state.warn("中间图片超过本地 32 MiB 总预算。")
479
+ continue
480
+ state.partial_bytes += len(partial.data)
481
+ state.result.partials.append(partial)
482
+ state.partial_keys.add(key)
483
+ await _notify(on_progress, {"stage": "partial_image", "index": index, "item_id": item_id}, state)
484
+ elif event_type in {"response.completed", "response.incomplete", "response.failed"}:
485
+ state.response(_get(event, "response"), terminal=True)
486
+ if event_type != "response.completed" and not state.result.errors:
487
+ state.error("response_incomplete", "上游响应未完整完成;已完成图片仍会保存。")
488
+ break
489
+ elif event_type == "error":
490
+ state.error("upstream_error", "上游流式请求报告错误;已完成图片仍会保存。")
491
+ elif event_type in {"response.image_generation_call.in_progress", "response.image_generation_call.generating"}:
492
+ await _notify(on_progress, {"stage": event_type.rsplit(".", 1)[-1], "item_id": state.identifier(_get(event, "item_id"))}, state)
493
+ except Exception:
494
+ state.error("stream_interrupted", "图片流发生连接或协议错误,已完成图片仍会保存;未自动重试。")
495
+ finally:
496
+ close = getattr(stream, "close", None) or getattr(stream, "aclose", None)
497
+ if close is not None:
498
+ pending = close()
499
+ if inspect.isawaitable(pending):
500
+ task = asyncio.create_task(pending)
501
+ try:
502
+ await asyncio.shield(task)
503
+ except asyncio.CancelledError:
504
+ await _settle(task)
505
+ raise
506
+ if not state.terminal and not any(error["code"] == "stream_interrupted" for error in state.result.errors):
507
+ state.error("stream_interrupted", "图片流在完整终态前结束,已完成图片仍会保存;未自动重试。")
508
+
509
+
510
+ def _record_failure(state: _State, error: Exception) -> None:
511
+ if isinstance(error, (APITimeoutError, TimeoutError)):
512
+ state.error("timeout", "操作超时。已完成图片会尽量保存;上游可能仍在处理,请确认后再重试以免重复计费。")
513
+ elif isinstance(error, APIStatusError):
514
+ state.result.request_id = state.identifier(getattr(error, "request_id", None)) or state.result.request_id
515
+ status = error.status_code
516
+ if status in {401, 403}:
517
+ code, message = "authentication_error", "上游拒绝认证或访问,请检查所选 provider 的凭据和权限。"
518
+ elif status == 429:
519
+ code, message = "rate_limit", "上游返回限流或额度不足,请检查额度或稍后重试。"
520
+ elif status in {400, 404, 422}:
521
+ code, message = "invalid_request", "上游拒绝请求,请核对模型、图像参数和 Responses 工具支持情况。"
522
+ else:
523
+ code, message = "upstream_http_error", "上游 HTTP 请求失败,未自动重试。"
524
+ state.error(code, message, http_status=status)
525
+ elif isinstance(error, APIConnectionError):
526
+ state.error("connection_error", "上游连接中断或不可达;已完成图片会尽量保存,未自动重试。")
527
+ elif isinstance(error, OpenAIError):
528
+ state.error("sdk_error", "上游 SDK 请求失败,请检查接口兼容性。")
529
+ else:
530
+ state.error("processing_error", "图像处理未完整完成;已完成图片会尽量保存。")
531
+
532
+
533
+ async def _finalize(state: _State, plan: Any, tool: dict[str, Any]) -> None:
534
+ if not state.decoded:
535
+ decoded = await _offload(_decode_items, state, tool, on_cancel=state.accept_decoded)
536
+ state.accept_decoded(decoded)
537
+ if state.prepared and not state.saved:
538
+ saved = await _offload(save_images, plan, state.prepared, on_cancel=state.accept_saved)
539
+ state.accept_saved(saved)
540
+
541
+
542
+ async def generate(
543
+ config: ResolvedConfig, *, prompt: str, cwd: str,
544
+ output_dir: str | None = None, filename: str | None = None,
545
+ output_format: str | None = None, size: str | None = None,
546
+ quality: str | None = None, background: str | None = None,
547
+ output_compression: int | None = None, action: str | None = None,
548
+ input_images: list[dict[str, Any]] | None = None, mask: dict[str, Any] | None = None,
549
+ input_fidelity: str | None = None, previous_response_id: str | None = None,
550
+ previous_image_ids: list[str] | None = None, moderation: str | None = None,
551
+ partial_images: int = 0, safety_identifier: str | None = None,
552
+ on_progress: ProgressCallback | None = None,
553
+ ) -> GenerationResult:
554
+ """Generate/edit with no automatic retry; retain validated completed assets.
555
+
556
+ The deadline covers local preparation, networking and processing. When it expires
557
+ or cancellation arrives, already-started worker threads finish their critical work
558
+ and reservations are released; this cleanup may extend past the deadline.
559
+ """
560
+ if not isinstance(prompt, str) or not prompt.strip():
561
+ raise ImageToolError("prompt 不能为空。")
562
+ _nonempty(config.model, "model:请指定当前会话模型或在 Codex 配置中设置模型")
563
+ if previous_response_id is not None:
564
+ _nonempty(previous_response_id, "previous_response_id")
565
+ if safety_identifier is not None:
566
+ _nonempty(safety_identifier, "safety_identifier")
567
+ selected_format = resolve_format(output_format, filename)
568
+ tool = validate_options(output_format=selected_format, size=size, quality=quality, background=background,
569
+ output_compression=output_compression, action=action, input_fidelity=input_fidelity,
570
+ moderation=moderation, partial_images=partial_images)
571
+ state = _State(config)
572
+ plan = None
573
+ try:
574
+ try:
575
+ async with asyncio.timeout(config.request_timeout):
576
+ plan = await _offload(prepare_save, cwd, output_dir, filename, selected_format,
577
+ on_cancel=lambda value: value.release())
578
+ request_input = await _offload(_prepare_input, prompt, cwd, tool, input_images, mask,
579
+ previous_response_id, previous_image_ids)
580
+ request: dict[str, Any] = {"model": config.model, "input": request_input, "tools": [tool],
581
+ "tool_choice": {"type": "image_generation"}, "extra_headers": _request_headers(config)}
582
+ if previous_response_id is not None:
583
+ request["previous_response_id"] = previous_response_id
584
+ if safety_identifier is not None:
585
+ request["safety_identifier"] = safety_identifier
586
+ client_options: dict[str, Any] = {
587
+ "base_url": config.base_url, "api_key": config.api_key or _header_auth_key,
588
+ "default_headers": config.headers, "default_query": config.query_params,
589
+ "timeout": config.request_timeout, "max_retries": 0,
590
+ }
591
+ if config.provider != "openai":
592
+ client_options.update(organization="", project="", admin_api_key="")
593
+ async with AsyncOpenAI(**client_options) as client:
594
+ if partial_images:
595
+ stream = await client.responses.create(**request, stream=True)
596
+ await _consume_stream(stream, state, on_progress)
597
+ else:
598
+ response = await client.responses.create(**request)
599
+ state.response(response, terminal=True)
600
+ await _finalize(state, plan, tool)
601
+ except ImageToolError:
602
+ raise
603
+ except asyncio.CancelledError:
604
+ # Preserve known completed items even when the caller abandons the reply.
605
+ if plan is not None and state.items:
606
+ try:
607
+ await _settle(asyncio.create_task(_finalize(state, plan, tool)))
608
+ except Exception:
609
+ pass
610
+ raise
611
+ except Exception as exc:
612
+ _record_failure(state, exc)
613
+ if plan is not None:
614
+ try:
615
+ await _finalize(state, plan, tool)
616
+ except Exception:
617
+ state.error("recovery_failed", "部分已完成图片无法恢复保存,请检查输出目录。")
618
+ if not state.result.images and not state.result.errors:
619
+ state.error("no_images", "上游没有返回可保存的 image_generation_call 图片。")
620
+ state.result.status = ("partial_success" if state.result.errors else "success") if state.result.images else "error"
621
+ return state.result
622
+ finally:
623
+ if plan is not None:
624
+ cleanup_errors = await _offload(plan.release)
625
+ if cleanup_errors:
626
+ state.result.errors.extend(cleanup_errors[:max(0, 32 - len(state.result.errors))])
627
+ state.result.status = "partial_success" if state.result.images else "error"