8275a04903
CI / ai-gates (push) Successful in 38s
新增 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 目录。
254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""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()
|