feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
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 目录。
This commit is contained in:
2026-09-21 12:10:07 +08:00
parent 062e11b1ff
commit 8275a04903
21 changed files with 2375 additions and 11 deletions
View File
+151
View File
@@ -0,0 +1,151 @@
"""gpt-image-2 的尺寸与参数约束。
与火山那边不同,OpenAI 的图片接口不用「分辨率 + 宽高比」两个字段,而是一个
`WIDTHxHEIGHT` 字符串,并对它施加几条**算术约束**。逐条取自官方文档:
- 宽高**都必须能被 16 整除**
- 宽高比必须在 `1:3` 到 `3:1` 之间
- 最大支持 `3840x2160`
- 超过 `2560x1440` 属**实验性**
- 另有「模型当前的像素与边长限制」——文档未给具体数值,故无法在本地校验
在本地先校验这些,是因为违反约束的请求会被 OpenAI 以 400 拒绝,而那要花一次
往返、计入配额,并表现为一个失败的任务。错误信息还能直接指出违反了哪一条。
**标准尺寸**(`1024x1024` / `1536x1024` / `1024x1536`)与 `auto` 一律放行,
不做算术校验:它们是文档明示支持的取值,其中 `auto` 由模型自行决定。
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
log = logging.getLogger(__name__)
#: 文档明示的标准尺寸,直接放行。
STANDARD_SIZES = frozenset({"1024x1024", "1536x1024", "1024x1536"})
#: 交给模型自选尺寸。
AUTO_SIZE = "auto"
#: `WIDTHxHEIGHT`。
_SIZE_PATTERN = re.compile(r"^(\d+)x(\d+)$")
#: 宽高必须同时是它的倍数。
EDGE_MULTIPLE = 16
#: 宽高比的允许区间(宽/高),即 1:3 ~ 3:1。
MIN_ASPECT = 1 / 3
MAX_ASPECT = 3.0
#: 最大支持尺寸,文档原文是「the maximum supported resolution is 3840x2160」。
#:
#: ⚠️ **这里有一处文档未明确、由我们推断的语义**:那句话没说清限制是「逐边」
#: (宽≤3840 且 高≤2160)还是「按总像素」。按逐边理解会**禁掉所有高于 2160 的
#: 竖图**,而 `1024x1536` 是文档明示的标准竖图尺寸、竖版显然受支持——故逐边理解
#: 必然过严。
#:
#: 采用的读法:**最长边 ≤ 3840,且总像素 ≤ 3840×2160**。它允许 `2160x3840`
#: 这类竖图(与横图同像素量),又拦住明显越界的尺寸。若日后拿到更明确的文档或
#: 实测结果,回来校正本处并更新取证页。
MAX_EDGE = 3840
MAX_PIXELS = 3840 * 2160
#: 超过此面积属实验性——放行但记一条日志,便于把偶发的异常表现与它关联起来。
#: 同样按面积而非逐边理解,理由同上。
EXPERIMENTAL_ABOVE_PIXELS = 2560 * 1440
#: 可选的画质档。xhigh 与 max 仅 2.5 系列支持,gpt-image-2 不含。
QUALITIES_GPT_IMAGE_2 = frozenset({"low", "medium", "high", "auto"})
#: 输出格式。注意 transparent 背景要求 png 或 webp。
OUTPUT_FORMATS = frozenset({"png", "jpeg", "webp"})
@dataclass(frozen=True)
class Size:
width: int
height: int
def __str__(self) -> str:
return f"{self.width}x{self.height}"
def parse_size(value: str) -> Size | None:
"""把 `WIDTHxHEIGHT` 解析成尺寸;标准尺寸与 `auto` 之外的非法串返回 None。"""
match = _SIZE_PATTERN.match(value.strip())
if not match:
return None
return Size(int(match.group(1)), int(match.group(2)))
def validate_size(value: str | None) -> None:
"""校验尺寸,不合法则抛 `ValueError`(消息指明违反了哪一条约束)。
`None`、`auto`、标准尺寸一律放行。
"""
if not value or value == AUTO_SIZE or value in STANDARD_SIZES:
return
size = parse_size(value)
if size is None:
raise ValueError(
f"size 形态非法:{value!r},应为 'WIDTHxHEIGHT'、'auto' "
f"或标准尺寸 {sorted(STANDARD_SIZES)}"
)
if size.width % EDGE_MULTIPLE or size.height % EDGE_MULTIPLE:
raise ValueError(f"size {size} 的宽高必须都能被 {EDGE_MULTIPLE} 整除")
# 宽高比先判:它是文档最明确的一条,且比尺寸上限更常被违反(比例写错比
# 尺寸写大更容易发生),先报它的错误信息更有用。
aspect = size.width / size.height
if not MIN_ASPECT <= aspect <= MAX_ASPECT:
raise ValueError(f"size {size} 的宽高比 {aspect:.2f} 超出允许区间 1:3 ~ 3:1")
if max(size.width, size.height) > MAX_EDGE:
raise ValueError(f"size {size} 的最长边超出上限 {MAX_EDGE}")
pixels = size.width * size.height
if pixels > MAX_PIXELS:
raise ValueError(
f"size {size} 的总像素 {pixels} 超出上限 {MAX_PIXELS}"
f"(即 3840x2160 的像素量)"
)
if pixels > EXPERIMENTAL_ABOVE_PIXELS:
# 放行但记一条:文档称实验性,出问题时这条日志能把现象与它关联起来。
log.info("size %s 超过 2560x1440,属服务商标注的实验性范围", size)
def validate_quality(value: str | None) -> None:
"""校验画质档。
只覆盖 gpt-image-2:`xhigh`/`max` 是 2.5 系列才支持的档,传给 gpt-image-2
会被拒。不按模型名分表是因为目前只接入一个图片模型——接入第二个时再分。
"""
if value and value not in QUALITIES_GPT_IMAGE_2:
raise ValueError(
f"quality {value!r} 不被 gpt-image-2 支持,"
f"可选:{sorted(QUALITIES_GPT_IMAGE_2)}"
f"(xhigh / max 仅 gpt-image-2.5 系列支持)"
)
def validate_output_format(value: str | None, *, transparent: bool = False) -> None:
"""校验输出格式。
:param transparent: 背景为透明时**只能** png 或 webp——jpeg 没有 alpha 通道,
指定它会得到一张背景被填成不透明的图,而那不会报错、只会静默不符合预期。
"""
if value and value not in OUTPUT_FORMATS:
raise ValueError(
f"output_format {value!r} 非法,可选:{sorted(OUTPUT_FORMATS)}"
)
if transparent and value == "jpeg":
raise ValueError(
"背景为 transparent 时 output_format 不能是 jpeg(无 alpha 通道),"
"请用 png 或 webp"
)
+161
View File
@@ -0,0 +1,161 @@
"""OpenAI 图片接口的 HTTP 传输层。
与火山那边的 `ArkClient` 职责相同(鉴权、超时、错误解包),但**刻意不共用**:
两家的错误体形状不同(OpenAI 是 `{"error": {message, type, code, param}}`,火山是
`{"error": {code, message}}`),且 OpenAI 的图片编辑走 multipart 而非 JSON。
强行抽一个共同基类会让两边都被对方的特例污染——供应商适配层之间**不该**互相
复用,各自复用自家的传输层才是分层的本意。
本模块不认识「图片生成」这个业务:以后接 OpenAI 的对话或语音接口时复用它。
"""
from __future__ import annotations
import logging
from typing import Any, Self
import httpx
from app.domain.generation import Failure
from .errors import classify
log = logging.getLogger(__name__)
#: 官方 API base URL。
DEFAULT_BASE_URL = "https://api.openai.com/v1"
class OpenAiError(RuntimeError):
"""OpenAI 返回的错误,携带领域分类。"""
def __init__(self, failure: Failure, http_status: int | None = None):
super().__init__(failure.message or failure.provider_code or "OpenAI 调用失败")
self.failure = failure
self.http_status = http_status
class OpenAiClient:
"""OpenAI API 的同步 HTTP 客户端。
**超时语义与火山那边不同,这里必须说清**:OpenAI 的图片接口是**同步**的——
一次请求里等到图片生成完再返回。因此这里的 `timeout_sec` **确实是生成耗时的
上限**,不是「短往返」的上限,默认值也因此比火山那边大一个量级。
这也是 `image.py` 必须在服务内部把它转成异步的原因(ADR-023 §4:同步型供应商
由其适配层在内部消化差异,绝不让它渗透到对外接口)。
"""
def __init__(
self,
api_key: str,
base_url: str = DEFAULT_BASE_URL,
timeout_sec: float = 180.0,
organization: str | None = None,
client: httpx.Client | None = None,
):
if not api_key:
raise ValueError(
"OPENAI_API_KEY 未配置:请通过环境变量注入,切勿写入代码或样例文件"
)
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._organization = organization
self._owns_client = client is None
self._client = client or httpx.Client(timeout=timeout_sec)
def post_json(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
return self._send("POST", path, json=body)
def post_multipart(
self,
path: str,
data: dict[str, Any],
files: list[tuple[str, tuple[str, bytes, str]]],
) -> dict[str, Any]:
"""multipart 提交,图像编辑接口需要它(`image` 是文件而非 URL)。
:param files: `(field_name, (filename, content, content_type))`。用 list
而非 dict 是因为**同名字段可以重复**——编辑接口支持传多张图,它们
共用字段名 `image[]`,dict 表达不了。
"""
return self._send("POST", path, data=data, files=files)
# ---------------------------------------------------------------- 内部
def _send(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
url = f"{self._base_url}{path}"
try:
response = self._client.request(
method, url, headers=self._headers(), **kwargs
)
except httpx.HTTPError as exc:
# 网络层失败按可重试处理。注意**同步接口的超时最危险**:请求可能已经
# 在服务商侧完成并计费,而我们拿不到结果。OpenAI 的图片接口没有任务
# ID 可供事后查询,故这个窗口无法通过"再查一次"弥补——只能由
# max_attempts 兜住。详见 image.py 的说明。
raise OpenAiError(
classify(None, message=f"请求 OpenAI 失败:{exc}")
) from exc
return self._unwrap(response)
def _headers(self) -> dict[str, str]:
headers = {"Authorization": f"Bearer {self._api_key}"}
if self._organization:
headers["OpenAI-Organization"] = self._organization
# 刻意不设 Content-Type:JSON 与 multipart 的取值不同,交给 httpx 按
# 实际载荷推断(multipart 还需要 boundary,手写必错)。
return headers
@staticmethod
def _unwrap(response: httpx.Response) -> dict[str, Any]:
"""把响应转成 dict;非 2xx 转成带分类的 `OpenAiError`。
错误体按 OpenAI 通用约定解析(`{"error": {message, type, code, param}}`)。
**该形状未经用户提供的文档证实**——两份文档都没有错误码一节,见
`errors.py` 顶部说明。解析不出这个形状时不静默,按状态码分类并把原始
文本带上。
"""
if response.is_success:
try:
return response.json()
except ValueError as exc:
raise OpenAiError(
classify(
response.status_code,
message=f"响应不是合法 JSON:{response.text[:200]}",
),
response.status_code,
) from exc
error_type: str | None = None
error_code: str | None = None
message = response.text[:500]
try:
error = response.json().get("error") or {}
error_type = error.get("type")
error_code = error.get("code")
message = error.get("message") or message
except ValueError:
pass
failure = classify(response.status_code, error_type, error_code, message)
log.warning(
"OpenAI 返回错误 http=%s type=%s code=%s retry=%s",
response.status_code,
error_type,
error_code,
failure.retry.value,
)
raise OpenAiError(failure, response.status_code)
def close(self) -> None:
# 只关自己创建的连接池:注入进来的归调用方管理。
if self._owns_client:
self._client.close()
def __enter__(self) -> Self:
return self
def __exit__(self, *exc_info: object) -> None:
self.close()
+105
View File
@@ -0,0 +1,105 @@
"""OpenAI 图片接口的错误 → 领域 `Failure`。
⚠️ **本模块的映射未经官方文档证实。**
用户提供的两份文档(`gpt生图.md` / `gpt图像编辑.md`)只覆盖了请求参数与返回体,
**完全没有错误码表**——这与火山那边给了完整错误码表的情况不同。因此这里的映射
建立在 OpenAI 通用错误约定之上:错误体形如
`{"error": {"message", "type", "code", "param"}}`,并以 HTTP 状态码作为主要依据。
**处置策略**:以 **HTTP 状态码为主、`type`/`code` 为辅**。状态码语义是 HTTP 规范
保证的,而 `type`/`code` 的取值集没有文档支撑,凭记忆列一张表只会得到一张看起来
权威、实则可能错漏的映射。故这里对 `type`/`code` 只做**少量高置信度的加细**,
其余一律按状态码归类。
**待补**:拿到官方错误码文档、或在真实调用中观察到实际取值后,回来校正本模块并
更新 patbond-doc 的取证页。在那之前,本文件顶部这段说明不要删。
"""
from __future__ import annotations
import logging
from app.domain.generation import Failure, Retry, UserFacing
log = logging.getLogger(__name__)
#: 高置信度的 `type` 取值:这几个在 OpenAI 的错误体里语义明确且与状态码一致,
#: 加细它们不会引入猜测——它们只是让分类比"仅看状态码"更准。
#: 未列出的 type 一律回落到状态码判定。
_TYPE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = {
# 配额/计费耗尽:等的是充值或下一个周期,重试无用。
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# 限流:等几秒。
"rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
# 内容被安全策略拒绝:同样的输入必然同样被拒。
"image_generation_user_error": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
}
#: 同上,但匹配 `code` 字段。
_CODE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = {
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
"rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
"moderation_blocked": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
"content_policy_violation": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
"invalid_api_key": (Retry.NEVER, UserFacing.SERVER_ERROR),
}
def classify(
http_status: int | None,
error_type: str | None = None,
error_code: str | None = None,
message: str | None = None,
) -> Failure:
"""把一次失败分类。
:param http_status: `None` 表示网络层失败(连接不上、超时),按可重试处理。
未知情形一律偏向**可退避重试**而非判死:把一次可恢复的抖动判成用户可见的
失败是更糟的错误,而重试预算本身有上限(`max_attempts`)。
"""
text = message or ""
# provider_code 优先用 code,其次 type——两者都缺时用状态码,保证这个字段
# 永远不为空(它是排查时的第一线索)。
provider_code = error_code or error_type or _status_code_label(http_status)
override = _CODE_OVERRIDES.get(error_code or "") or _TYPE_OVERRIDES.get(
error_type or ""
)
if override:
retry, user_facing = override
return Failure(retry, user_facing, provider_code, text)
if http_status is None:
return Failure(
Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text
)
if http_status == 429:
# 429 在 OpenAI 上同时用于「限流」与「配额耗尽」,靠 type/code 区分;
# 上面的 override 没命中说明拿不到那两个字段,此时**按限流处理**:
# 猜成配额耗尽会让一次本可重试的限流被判死,代价更大。
log.info("429 但无 type/code 可据,按限流处理(http=%s)", http_status)
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, provider_code, text)
if http_status in (401, 403):
# 密钥无效或无权访问:我方配置问题,重试无用。
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
if http_status == 400:
# 参数问题**或**内容被拒——OpenAI 两者都用 400。拿不到 type/code 时无法
# 区分,按不可重试处理(两种情形重试同一请求都不会变好),用户侧归
# SERVER_ERROR 而不猜成 CONTENT_REJECTED:错报「内容被拒」会让用户去改
# 一个其实没问题的提示词。
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
if 400 <= http_status < 500:
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
# 5xx 与其余一切:可重试。
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text)
def _status_code_label(http_status: int | None) -> str:
return f"HTTP_{http_status}" if http_status else "NETWORK_ERROR"
+253
View File
@@ -0,0 +1,253 @@
"""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()
+149
View File
@@ -0,0 +1,149 @@
"""OpenAI 图片接口的请求/响应 DTO。
只放**厂商词汇**。领域概念在 `app/domain/generation.py`,翻译在 `image.py`。
一处值得注意的厂商差异:同一份请求体在**文生图走 JSON、图生图走 multipart**
(后者的 `image` 是文件)。故本类提供两种输出形态——`to_json_body()` 与
`to_form_fields()`。multipart 的字段值必须是字符串,布尔与数字要转,不能直接塞。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ImageSubmission:
"""一次图片生成请求。
刻意只暴露 Patbond 会用到的参数。厂商参数远多于此(`n`、`partial_images`、
`stream`、`mask`、`input_fidelity`、`output_compression`、`moderation`…),
多暴露一个就多一处要在契约、客户端、文档三处同步维护的地方。
:param n: **固定为 1,不开放**。领域模型的一个任务对应一个产物
(`generation_jobs.output_asset_id` 是单值);允许 n>1 就得改数据模型。
:param quality: `None` 交给服务商默认(auto)。注意 `xhigh`/`max` 仅
gpt-image-2.5 系列支持,gpt-image-2 传了会被拒(`capabilities` 会先拦)。
:param background: `transparent` 时 `output_format` 只能 png/webp。
文档称 gpt-image-2 对透明背景的支持**处于预览**,故默认不指定。
:param safety_identifier: 对应厂商的 `user` 字段——终端用户标识,供其做滥用
检测。必须是哈希,不可传明文用户 ID。
"""
model: str
prompt: str
size: str | None = None
quality: str | None = None
output_format: str | None = None
background: str | None = None
safety_identifier: str | None = None
def to_json_body(self) -> dict[str, Any]:
"""文生图(`/images/generations`)的 JSON 体。"""
body: dict[str, Any] = {
"model": self.model,
"prompt": self.prompt,
# 一个任务一个产物,见类 docstring。
"n": 1,
}
self._add_optional(body)
return body
def to_form_fields(self) -> dict[str, str]:
"""图生图(`/images/edits`)的 multipart 文本字段。
multipart 的字段值必须是字符串——数字与布尔直接塞进去会被 httpx 拒绝或
编码成意外形态。
"""
fields: dict[str, Any] = {
"model": self.model,
"prompt": self.prompt,
"n": 1,
}
self._add_optional(fields)
return {key: str(value) for key, value in fields.items()}
def _add_optional(self, target: dict[str, Any]) -> None:
"""只加已设置的可选项。
不设的字段**留空而不显式传 null**:服务商对每个参数都有文档化的默认值,
显式 null 可能覆盖掉那些默认值。
"""
if self.size:
target["size"] = self.size
if self.quality:
target["quality"] = self.quality
if self.output_format:
target["output_format"] = self.output_format
if self.background:
target["background"] = self.background
if self.safety_identifier:
# 厂商字段名是 `user`,不是 safety_identifier。
target["user"] = self.safety_identifier
@dataclass(frozen=True)
class GeneratedImage:
"""`data[]` 里的一张图。
:param b64_json: base64 编码的图片。**GPT image 系列始终返回它**
(`response_format` 对这些模型不支持,`url` 只用于 dall-e)。
"""
b64_json: str | None = None
url: str | None = None
revised_prompt: str | None = None
@dataclass(frozen=True)
class ImagesResponse:
"""`/images/generations` 与 `/images/edits` 的共同响应形状。
:param size: `WIDTHxHEIGHT` 字符串。**像素宽高由此直接得到**,不像火山那边
只给 ratio+resolution、需要一张随模型族变化的对照表。
:param total_tokens: 计费对账依据(`usage.total_tokens`)。
"""
created: int | None = None
data: tuple[GeneratedImage, ...] = ()
size: str | None = None
output_format: str | None = None
quality: str | None = None
background: str | None = None
total_tokens: int | None = None
input_tokens: int | None = None
output_tokens: int | None = None
_raw: dict[str, Any] | None = None
def first_image(self) -> GeneratedImage | None:
return self.data[0] if self.data else None
def raw_keys(self) -> list[str]:
"""原始响应的顶层键,仅用于「2xx 但没有图」时的日志。"""
return sorted(self._raw) if self._raw else []
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> ImagesResponse:
usage = payload.get("usage") or {}
images = tuple(
GeneratedImage(
b64_json=item.get("b64_json"),
url=item.get("url"),
revised_prompt=item.get("revised_prompt"),
)
for item in (payload.get("data") or [])
if isinstance(item, dict)
)
return cls(
created=payload.get("created"),
data=images,
size=payload.get("size"),
output_format=payload.get("output_format"),
quality=payload.get("quality"),
background=payload.get("background"),
total_tokens=usage.get("total_tokens"),
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
_raw=payload,
)
+224
View File
@@ -0,0 +1,224 @@
"""同步型供应商的任务注册表:把「一次调用等到底」转成「提交 + 查询」。
## 为什么必须有它
ADR-023 §4 定的是「Java↔Python 边界一律异步,同步型供应商由其适配层在内部消化
这个差异」。火山的视频接口本身异步,所以视频适配器不需要这一层。**OpenAI 的图片
接口是同步的**——`POST /images/generations` 在一次请求里等到图片生成完才返回,
没有任务 ID,也没有查询接口。差异必须在这里被吸收,否则就会渗透到客户端契约、
等待态设计和 E2E,那是 §4 明确要避免的。
## 这与「Python 不持有状态」的张力,以及取舍
Seedance 那条路径上,Python 确实无状态:任务状态的唯一事实来源是 Java 的
`generation_jobs`,Python 只是个转发器。**图片这条路径做不到**——同步接口没有可
供事后查询的厂商 ID,所以「在途任务」这件事只能记在本进程内存里。
代价与处置如实登记:
1. **进程重启会丢失在途任务。** 之后 Java 轮询会得到 `task_not_found`,任务被判
终态失败,而**那次调用可能已经完成并计费**。OpenAI 的图片接口没有任务 ID 可供
事后查询,所以这个窗口**无法靠"再查一次"弥补**,只能由 `max_attempts` 兜住。
这与火山那边「提交超时不知道有没有扣费」是同一类风险,但触发面更大(那边只有
提交超时,这边还包括进程重启)。
2. **产物是 base64 字节,只在内存里。** 完成的任务必须在 TTL 内被取走,否则丢失。
而转存阻塞于对象存储决策(ADR-023 §7),所以**图片链路在存储决策落地前比视频
更受限**:视频至少有个 24 小时的厂商 URL 可以先看效果,图片的字节一旦被清理
就什么都没有了。
3. 注册表**有界且有 TTL**,否则它就是个内存泄漏。
## 为什么用线程而不是 asyncio
FastAPI 的端点在本服务里是同步函数(`def` 而非 `async def`),且 `httpx.Client`
是同步客户端。用线程池承载那个长调用与现有代码一致;改成 asyncio 需要把传输层、
端点、测试全部改成异步,收益只是少几个线程——本服务的并发量级(一台轻量服务器上
的少量生成请求)用不上那个收益。
"""
from __future__ import annotations
import logging
import threading
import time
import uuid
from collections.abc import Callable
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, field
from app.domain.generation import Failure, GenerationResult, TaskState
log = logging.getLogger(__name__)
#: 本地任务 ID 的前缀,便于在日志里与厂商 ID 区分开。
LOCAL_TASK_PREFIX = "local-"
@dataclass
class _Entry:
"""一条在途或已完成的任务。"""
future: Future
created_at: float = field(default_factory=time.monotonic)
#: **首次观察到**完成的时刻;未完成为 None。TTL 从这里起算而不是从创建起算——
#: 否则一个耗时接近 TTL 的任务会在刚完成时就被清理掉,结果还没被取走。
#:
#: 为什么是「首次观察到」而不是「实际完成」:实际完成时刻需要一个
#: `add_done_callback` 才能捕捉,而那会**死锁**——快速完成的任务在
#: `add_done_callback` 时已经 done,回调便**同步地在调用线程执行**,而该线程
#: 此刻正持有本注册表的锁(非可重入)。改用惰性记录既无此风险,又只会让结果
#: 多留一会儿(保守方向),不会让它提前消失。
finished_at: float | None = None
class SyncTaskRegistry:
"""把同步调用包装成可轮询的任务。
线程安全:`submit`/`fetch`/清理可能来自不同请求线程。
"""
def __init__(
self,
*,
max_workers: int = 4,
completed_ttl_sec: float = 900.0,
max_entries: int = 256,
):
"""
:param max_workers: 并发执行的同步调用数上限。**同时也是对服务商并发的
自我限制**:超出的提交会排队而不是一起打过去,既避免把自己的 RPM 打爆,
也让「同时有多少钱在花」有个上界。
:param completed_ttl_sec: 已完成任务的保留时长。需大于 Java 侧最长的轮询
间隔(当前阶梯最长 30 秒),否则结果会在被取走之前被清理。默认 15
分钟留足余量。
:param max_entries: 注册表容量上限。满了先清理过期项,仍满则拒绝新提交
(而不是无声挤掉某个在途任务——那会让一个已经在计费的调用变成孤儿)。
"""
self._executor = ThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix="sync-gen"
)
self._completed_ttl_sec = completed_ttl_sec
self._max_entries = max_entries
self._entries: dict[str, _Entry] = {}
self._lock = threading.Lock()
def submit(self, work: Callable[[], GenerationResult]) -> str:
"""安排一次同步调用在后台执行,立刻返回本地任务 ID。
:raises RuntimeError: 注册表已满(并发在途任务过多)
"""
with self._lock:
self._evict_expired_locked()
if len(self._entries) >= self._max_entries:
raise RuntimeError(
f"在途生成任务已达上限 {self._max_entries},请稍后重试"
)
task_id = LOCAL_TASK_PREFIX + uuid.uuid4().hex
# 刻意不注册 done_callback,原因见 _Entry.finished_at 的说明(会死锁)。
self._entries[task_id] = _Entry(future=self._executor.submit(work))
log.info("已受理同步生成任务 %s(在途 %d)", task_id, len(self._entries))
return task_id
def fetch(self, task_id: str) -> GenerationResult | None:
"""查任务;未知 ID 返回 None。
未知 ID 的两种成因**无法区分**:从未存在过,或进程重启后丢失。两者对调用
方的处置相同(停止轮询、任务判失败),故不强行区分——区分不了的事不要假装
区分。
"""
with self._lock:
entry = self._entries.get(task_id)
if entry is not None and entry.finished_at is None and entry.future.done():
# 首次观察到完成:记下时刻,TTL 由此起算。
entry.finished_at = time.monotonic()
if entry is None:
return None
if not entry.future.done():
# 同步接口不提供进度,故只能报 0。客户端契约本就不承诺平滑进度
# (ADR-023 §10),一个诚实的 0 不该被读成「卡住」。
return GenerationResult(
provider_request_id=task_id, state=TaskState.RUNNING
)
try:
result = entry.future.result()
except Exception as exc: # noqa: BLE001 - 兜底转成领域失败
return self._failure_result(task_id, exc)
# 用真实的任务 ID 覆盖工作函数里可能留空的那个字段。
return GenerationResult(
provider_request_id=task_id,
state=result.state,
progress=result.progress,
asset_url=result.asset_url,
asset_url_expires_in_sec=result.asset_url_expires_in_sec,
cover_url=result.cover_url,
ratio=result.ratio,
resolution=result.resolution,
width_px=result.width_px,
height_px=result.height_px,
duration_ms=result.duration_ms,
output_format=result.output_format,
failure=result.failure,
billing_tokens=result.billing_tokens,
)
@staticmethod
def _failure_result(task_id: str, exc: BaseException) -> GenerationResult:
"""工作函数抛出的异常转成领域失败。
已分类的服务商异常保留其分类;其余按可重试的上游问题处理,并把类型名带进
`provider_code`——否则排查时只有一句空洞的错误消息。
"""
from app.domain.generation import Retry, UserFacing
from .client import OpenAiError
if isinstance(exc, OpenAiError):
failure = exc.failure
else:
log.exception("同步生成任务 %s 抛出未分类异常", task_id)
failure = Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
type(exc).__name__,
str(exc),
)
return GenerationResult(
provider_request_id=task_id, state=TaskState.FAILED, failure=failure
)
def _evict_expired_locked(self) -> None:
"""清理已完成且超过 TTL 的条目。调用方必须已持锁。
**只清已完成的**:在途任务无论多久都不清——它可能正在花钱,清掉会让它
变成一个无人认领的开销。真正卡死的在途任务由线程池的上限兜住。
"""
now = time.monotonic()
# 顺带给「已完成但从未被 fetch 过」的条目补上观察时刻,否则它们的
# finished_at 永远是 None,就永远不会过期——那正是内存泄漏。
for entry in self._entries.values():
if entry.finished_at is None and entry.future.done():
entry.finished_at = now
expired = [
task_id
for task_id, entry in self._entries.items()
if entry.finished_at is not None
and now - entry.finished_at > self._completed_ttl_sec
]
for task_id in expired:
del self._entries[task_id]
if expired:
log.debug("清理了 %d 个已完成的同步任务条目", len(expired))
@property
def size(self) -> int:
with self._lock:
return len(self._entries)
def shutdown(self) -> None:
"""停止接受新任务。
`wait=False`:在途的同步调用可能还要跑几分钟,进程退出时不等它们——结果
反正已经无处可交付了。它们会随进程一起消失,这正是上面第 1 点登记的风险。
"""
self._executor.shutdown(wait=False, cancel_futures=True)