Files
patbond-ai/app/providers/openai/image.py
T
lixi 8275a04903
CI / ai-gates (push) Successful in 38s
feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
新增 app/providers/openai/:client 传输(JSON + multipart)、errors 错误映射、
models 厂商 DTO、capabilities 尺寸算术约束、tasks 同步→异步桥、image 图片业务。
装配与配置同步接入,缺 OPENAI_API_KEY 时跳过装配、服务仍可启动。

与火山视频的三处根本差异及其处置:
- 接口同步(无任务 ID、无查询端点)→ SyncTaskRegistry 在本服务内部吸收,
  对外仍是 ADR-023 §4 的两段式提交/轮询。代价:本服务从此持有状态,进程重启
  丢在途任务,且横向扩容前须先解决任务 ID 只在签发进程内有效的问题。
- 产物是 base64 字节而非 URL → 无过期问题,但字节只在内存里。asset_url 放
  data URI 仅为满足领域不变式,绝不能原样写进 media.assets.external_url。
- 图生图走 multipart → 先下载参考图再上传;下载失败与超限按参数错误同步拒绝,
  不变成注定失败的后台任务。

两处需要记住的判断:
- 尺寸上限「3840x2160」文档未说清是逐边还是按像素。逐边理解会禁掉所有高于
  2160 的竖图,而 1024x1536 是文档明示的标准竖图,故必然过严。采用最长边
  ≤3840 且总像素 ≤3840×2160,已在 capabilities.py 与取证页标为推断。
- 两份文档都没有错误码表,故 errors.py 以 HTTP 状态码为主、type/code 为辅,
  顶部标注「未经文档证实」。429 无线索时按限流而非配额耗尽(猜成配额会把可
  重试的限流判死);400 无线索时归服务端错误而非内容被拒(错报会让用户去改
  一个其实没问题的提示词)。

一处死锁已修并回归钉住:SyncTaskRegistry 原用 add_done_callback 记录完成时刻,
而快速完成的任务在注册时已 done,回调便同步地在持有非可重入锁的线程里执行。
改为首次被观察到时惰性记录。

测试 166 → 271(+105)。四道门禁逐条读退出码,全绿。
产物转存仍阻塞于存储决策(ADR-023 §7),图片模型因此未播种进 V7 目录。
2026-09-21 12:10:07 +08:00

254 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()