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
+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"