Files
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

163 lines
7.0 KiB
Python

"""与服务商无关的生成领域模型。
**本模块不得 import 任何服务商代码,也不得 import httpx。** 这不是洁癖,是本层
存在的唯一理由——Java 侧、未来的 gpt-image-2、以及任何第三家供应商都对着这一层
编程。一旦这里漏进一个厂商概念,「换供应商不动上层」就不再成立,而那正是分层
想买到的东西。
判断某个概念该放这里还是放 `providers/<厂商>/`:问「换一家供应商,这个概念还在
吗」。`Retry`/`UserFacing` 描述的是 **Worker 与客户端该怎么做**,与谁家供应商
无关,故在此;`expired` 状态、`QuotaExceeded` 这类码是火山特有的,故不在此。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class MediaKind(Enum):
"""生成产物的类型,与 `creation.generation_jobs.media_kind` 同值。"""
IMAGE = "image"
VIDEO = "video"
class Retry(Enum):
"""失败后该怎么办。由服务商适配层判定,由 Java 侧的 Worker 执行。"""
#: 不可重试:同样的输入必然得到同样的结果(参数错、内容被拒、鉴权失败)。
NEVER = "never"
#: 可重试且应尽快:限流、突发流量保护、服务过载。退避几秒到几十秒。
BACKOFF = "backoff"
#: 可重试但要等很久:额度/配额耗尽,通常要等到下一个计费周期或充值之后。
#: Worker 不该把重试预算浪费在这上面,应直接终态失败并让用户看到出口。
QUOTA = "quota"
class UserFacing(Enum):
"""给终端用户的呈现类别,对应客户端的两个 429 分支(ADR-023 §9)。
「配额耗尽」与「频率过快」必须分开:前者等的是明天或充值(弹 sheet 给出口),
后者等的是几秒(弹 SnackBar 可自动重试)。合成一类必然有一半场景的文案和
出口是错的。
"""
QUOTA_EXHAUSTED = "quota_exhausted"
RATE_LIMITED = "rate_limited"
CONTENT_REJECTED = "content_rejected"
SERVER_ERROR = "server_error"
UPSTREAM_UNAVAILABLE = "upstream_unavailable"
@dataclass(frozen=True)
class Failure:
"""一次失败的完整描述。
:param provider_code: 原始厂商错误码,**原样保留、绝不丢弃**——排查时它往往
是唯一线索。领域层不理解它的含义,只负责把它带到日志和响应里。
"""
retry: Retry
user_facing: UserFacing
provider_code: str
message: str = ""
class TaskState(Enum):
"""与服务商无关的任务状态。
刻意比火山的状态集**更小**:火山有 queued / running / cancelled / succeeded /
failed / expired 六个,这里把 queued 与 running 合成 PENDING/RUNNING 两态、
把 expired 归入 FAILED(它是超时,属于失败的一种,重试策略由 `Failure`
单独表达)。映射的取舍放在适配层,领域层只保留上层真正需要区分的状态。
"""
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
@property
def is_terminal(self) -> bool:
return self in {TaskState.SUCCEEDED, TaskState.FAILED, TaskState.CANCELLED}
@dataclass(frozen=True)
class GenerationRequest:
"""一次生成请求,服务商无关。
这是 Java 侧发过来的内容。**刻意不含 `user_id`**:本服务干活只需要
`job_id`、生成参数与上传目标,传用户标识属于不必要的扩散(ADR-023 §5
数据最小化)。需要给厂商做滥用检测时传的是 `safety_identifier`——一个哈希,
不是明文用户 ID。
:param model: 厂商侧的模型标识,由 Java 侧从目录表的 `provider_model_name`
快照取值后传入。**适配层不自行选模型**——选型是目录的职责,且任务行里
冻结的快照必须与实际调用的模型一致。
:param input_url: 参考图/首帧的公网可达 URL。为空即文生视频(参考图选填,
决策 A2)。
:param upload_target: 产物的预签名 PUT 地址。当前未启用(转存阻塞于存储
决策,ADR-023 §7),保留字段使将来启用无需改契约。
"""
job_id: str
media_kind: MediaKind
model: str
prompt: str | None = None
input_url: str | None = None
ratio: str | None = None
resolution: str | None = None
#: 像素尺寸。**图片路径用它,视频路径用 ratio+resolution**——两家服务商的
#: 尺寸表达方式不同:火山用「分辨率档 + 宽高比」,OpenAI 用 WIDTHxHEIGHT。
#: 领域层同时保留两种表达而不强行统一,是因为任何统一都会在某一侧失真
#: (把 720p+16:9 折成像素会丢掉"档位"语义,把像素折成比例会丢掉精确值)。
width_px: int | None = None
height_px: int | None = None
duration_sec: int | None = None
generate_audio: bool = True
watermark: bool = False
safety_identifier: str | None = None
callback_url: str | None = None
upload_target: str | None = None
@dataclass(frozen=True)
class GenerationResult:
"""一次查询的结果,服务商无关。
:param asset_url: 产物 URL。**可能是有时效的厂商地址**——火山 Seedance 的
有效期是 24 小时,因此调用方必须在有效期内转存,不可长期保存此值。
适配层通过 `asset_url_expires_in_sec` 告知时效,避免上层把它当永久地址。
:param cover_url: 封面图 URL(视频场景)。火山的尾帧图可免费充当此用途。
:param width_px: 像素宽高。**火山不直接返回像素值**,只返回 `ratio` 与
`resolution`;换算需要一张随模型family变化的对照表。故此处在 V7 目录
种子定下模型族之前保持 None,并把 `ratio`/`resolution` 原样透出,
由上层或后续实现换算——编一个可能错的尺寸比留空更糟。
:param billing_tokens: 计费对账依据(火山为 `usage.completion_tokens`)。
"""
provider_request_id: str
state: TaskState
progress: int = 0
asset_url: str | None = None
asset_url_expires_in_sec: int | None = None
cover_url: str | None = None
ratio: str | None = None
resolution: str | None = None
width_px: int | None = None
height_px: int | None = None
duration_ms: int | None = None
output_format: str | None = None
failure: Failure | None = None
billing_tokens: int | None = None
def __post_init__(self) -> None:
# 失败态必须带 Failure,否则上层无法决定重试策略,只能盲目重试或盲目
# 放弃——两者都错。宁可在这里炸掉也不要把半个失败态放出去。
if self.state is TaskState.FAILED and self.failure is None:
raise ValueError("FAILED 状态必须携带 Failure")
if self.state is TaskState.SUCCEEDED and not self.asset_url:
raise ValueError("SUCCEEDED 状态必须携带 asset_url")