"""gpt-image-2 图片生成:文生图与图生图(图像编辑)。 ## 与火山视频适配器的三处根本差异 1. **接口是同步的。** `POST /images/generations` 一次请求等到底,**没有任务 ID、 没有查询接口**。差异由 `tasks.SyncTaskRegistry` 在本服务内部吸收(ADR-023 §4), 代价与风险见该模块顶部说明。 2. **产物是 base64 字节,不是 URL。** GPT image 系列**始终**返回 base64 (`response_format` 对它们不支持)。好处是没有「URL 24 小时过期」问题;坏处是 字节只在内存里,必须立刻转存——而转存阻塞于存储决策(ADR-023 §7)。 3. **图生图走 multipart,不是 JSON。** `/images/edits` 的 `image` 字段是文件。 我们的输入是一个 URL,故需先把它下载下来再作为 multipart 上传。 ## 尺寸从响应直接拿,不需要对照表 响应带 `size` 字段(`WIDTHxHEIGHT`),故像素宽高是确定值——不像火山那边只给 `ratio`+`resolution`、需要一张随模型族变化的对照表。 """ from __future__ import annotations import logging import httpx from app.domain.generation import ( Failure, GenerationRequest, GenerationResult, MediaKind, Retry, TaskState, UserFacing, ) from app.providers.base import GenerationProvider from .capabilities import ( parse_size, validate_output_format, validate_quality, validate_size, ) from .client import OpenAiClient from .models import ImagesResponse, ImageSubmission from .tasks import SyncTaskRegistry log = logging.getLogger(__name__) #: `creation.generation_models.provider_code` 里对应本适配器的值。 PROVIDER_CODE = "openai-gpt-image" #: 下载参考图的超时。这是一次内部/公网取图,与生成耗时无关,故很短。 _INPUT_FETCH_TIMEOUT_SEC = 15.0 #: 参考图大小上限。服务商限制单图 < 50MB;这里取更小的值,因为我们还要把它整个 #: 读进内存再转发——一张几十 MB 的图会让内存占用随并发线性上涨。 _MAX_INPUT_BYTES = 20 * 1024 * 1024 class OpenAiImageProvider(GenerationProvider): """gpt-image-2 的图片生成适配器。""" def __init__( self, client: OpenAiClient, registry: SyncTaskRegistry, default_model: str = "", input_fetch_client: httpx.Client | None = None, ): self._client = client self._registry = registry self._default_model = default_model self._owns_fetch_client = input_fetch_client is None self._fetch_client = input_fetch_client or httpx.Client( timeout=_INPUT_FETCH_TIMEOUT_SEC, follow_redirects=True ) @property def provider_code(self) -> str: return PROVIDER_CODE @property def media_kind(self) -> MediaKind: return MediaKind.IMAGE # ------------------------------------------------------------- submit def submit(self, request: GenerationRequest) -> str: if request.media_kind is not MediaKind.IMAGE: raise ValueError( f"{PROVIDER_CODE} 只处理图片生成,收到 {request.media_kind.value}" ) submission = self._to_submission(request) # 参数校验放在提交进注册表**之前**:违规请求应当立刻以 400 回给调用方, # 而不是变成一个注定失败的后台任务——后者要等一轮轮询才被发现,还占了一个 # 线程池槽位。 validate_size(submission.size) validate_quality(submission.quality) validate_output_format( submission.output_format, transparent=submission.background == "transparent" ) if request.input_url: # 图生图:先取图。**取图也放在受理之前**——取不到就是参数问题 # (URL 不可达),应当立刻拒绝而不是变成一个后台失败。 image_bytes, filename, content_type = self._fetch_input(request.input_url) work = lambda: self._run_edit( submission, image_bytes, filename, content_type ) else: work = lambda: self._run_generate(submission) # 注册表满时 `submit` 抛 RuntimeError,这里**刻意不接**:归成 ValueError 让上层 # 映射成 invalid_request 是错的——那不是请求的问题而是容量问题,且稍后重试会 # 好。原样上抛,由端点映射为 provider_unavailable(503)。 return self._registry.submit(work) def _to_submission(self, request: GenerationRequest) -> ImageSubmission: """领域请求 → 厂商请求体。 模型优先取请求里的(来自任务行冻结的快照),仅在缺失时回落到配置的默认值 ——适配层不自行选模型,且任务行的快照必须与实际调用一致,否则无法对账。 """ model = request.model or self._default_model if not model: raise ValueError( "未指定模型:请求未携带 model 且 OPENAI_IMAGE_MODEL 未配置" ) return ImageSubmission( model=model, prompt=request.prompt or "", # 领域层的 ratio/resolution 是为视频设计的;图片用像素尺寸。宽高都有 # 才拼 size,否则交给模型自选(auto)——半个尺寸不如不给。 size=( f"{request.width_px}x{request.height_px}" if request.width_px and request.height_px else None ), safety_identifier=request.safety_identifier, ) # -------------------------------------------------------------- fetch def fetch(self, provider_request_id: str) -> GenerationResult: result = self._registry.fetch(provider_request_id) if result is None: # 未知 ID:从未存在过,或进程重启后丢失(两者无法区分,见 tasks.py)。 # 抛 KeyError 让端点映射成 task_not_found——调用方要做的是停止轮询, # 而不是退避重试。 raise KeyError(provider_request_id) return result # ------------------------------------------------- 实际的同步调用 def _run_generate(self, submission: ImageSubmission) -> GenerationResult: """文生图。在注册表的后台线程里执行。""" payload = self._client.post_json( "/images/generations", submission.to_json_body() ) return self._to_result(ImagesResponse.from_payload(payload)) def _run_edit( self, submission: ImageSubmission, image_bytes: bytes, filename: str, content_type: str, ) -> GenerationResult: """图生图(图像编辑)。走 multipart。""" payload = self._client.post_multipart( "/images/edits", data=submission.to_form_fields(), # 字段名用 `image[]`:编辑接口支持多图且共用该字段名。单图时也用它, # 避免"单图一种写法、多图另一种"的分叉。 files=[("image[]", (filename, image_bytes, content_type))], ) return self._to_result(ImagesResponse.from_payload(payload)) def _to_result(self, response: ImagesResponse) -> GenerationResult: """厂商响应 → 领域结果。""" image = response.first_image() if image is None or not image.b64_json: # 2xx 却没有图片数据。当成可重试的上游异常,而不是把一个没有产物的 # 「成功」放给上层去空指针。 log.error("OpenAI 返回 2xx 但缺少图片数据:%r", response.raw_keys()) return GenerationResult( provider_request_id="", state=TaskState.FAILED, failure=Failure( Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "MissingImageData", "服务商返回成功但响应中没有图片数据", ), ) size = parse_size(response.size or "") return GenerationResult( # 真实 ID 由注册表在 fetch 时填入——这里填不了,因为工作函数运行时 # 还拿不到自己的任务 ID。 provider_request_id="", state=TaskState.SUCCEEDED, progress=100, # ⚠️ 产物是 base64 字节而非可访问 URL。这里放 data URI 只是为了让领域 # 模型的「SUCCEEDED 必须有 asset_url」不变式成立,**它不是一个可长期 # 保存的地址**:一张 1024×1024 的 png 约 1~2MB,base64 后更大,绝不 # 应该原样写进 media.assets.external_url。转存落地后这里应改为返回 # 上传后的 object key。 asset_url=f"data:image/{response.output_format or 'png'};base64," + image.b64_json, # 无时效——字节在手,不像火山 URL 会过期。用 None 而非一个假的秒数。 asset_url_expires_in_sec=None, width_px=size.width if size else None, height_px=size.height if size else None, output_format=response.output_format, billing_tokens=response.total_tokens, ) # ------------------------------------------------------------ helpers def _fetch_input(self, url: str) -> tuple[bytes, str, str]: """下载参考图,供 multipart 上传。 服务商要求 png / webp / jpg 且单图 < 50MB。这里额外设了更小的上限,因为 字节要整个进内存再转发。 """ try: with self._fetch_client.stream("GET", url) as response: response.raise_for_status() declared = response.headers.get("Content-Length") if declared and int(declared) > _MAX_INPUT_BYTES: raise ValueError( f"参考图过大({declared} 字节),上限 {_MAX_INPUT_BYTES}" ) chunks: list[bytes] = [] total = 0 for chunk in response.iter_bytes(): total += len(chunk) if total > _MAX_INPUT_BYTES: # 不信 Content-Length:它可能缺失或撒谎,边读边数才拦得住。 raise ValueError(f"参考图超过上限 {_MAX_INPUT_BYTES} 字节") chunks.append(chunk) content_type = response.headers.get("Content-Type", "image/png") except httpx.HTTPError as exc: raise ValueError(f"参考图无法下载:{url}({exc})") from exc return b"".join(chunks), "input.png", content_type.split(";")[0] def close(self) -> None: if self._owns_fetch_client: self._fetch_client.close()