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.
- codex_image_generation/__init__.py +10 -0
- codex_image_generation/__main__.py +6 -0
- codex_image_generation/cli.py +73 -0
- codex_image_generation/config.py +395 -0
- codex_image_generation/errors.py +5 -0
- codex_image_generation/generator.py +627 -0
- codex_image_generation/server.py +240 -0
- codex_image_generation/storage.py +526 -0
- codex_image_generation-0.3.0.dist-info/METADATA +185 -0
- codex_image_generation-0.3.0.dist-info/RECORD +13 -0
- codex_image_generation-0.3.0.dist-info/WHEEL +4 -0
- codex_image_generation-0.3.0.dist-info/entry_points.txt +3 -0
- codex_image_generation-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"
|