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

143 lines
5.1 KiB
Python

"""请求/响应的 pydantic 模型。
字段名为 **snake_case**,与契约一致。这使 JSON、pydantic 模型、领域 dataclass
三者字段逐一对应,**无需任何转换层**——若用 camelCase,两端都要加转换。
这层与 `app/domain/generation.py` 的分工:领域模型是纯数据、无框架依赖(那是
「换供应商不动上层」的支点);本层负责 HTTP 边界的校验与序列化。两者字段重合,
但让领域层依赖 pydantic 会把框架拖进本该最干净的一层。
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.generation import (
GenerationRequest,
GenerationResult,
MediaKind,
)
class SubmitRequest(BaseModel):
"""提交生成任务。
**刻意不含 `user_id`**(数据最小化,ADR-023 §5):本服务干活只需要任务参数。
需要给服务商做滥用检测时传的是 `safety_identifier`——一个哈希,不是明文 ID。
"""
# 拒绝未声明的字段:调用方拼错字段名时应当立刻知道,而不是让那个参数被静默
# 忽略——后者会表现为「我传了 duration 却没生效」这类极难查的问题。
model_config = ConfigDict(extra="forbid")
job_id: str
media_kind: MediaKind
model: str = Field(min_length=1)
prompt: str | None = Field(default=None, max_length=10000)
input_url: str | None = None
ratio: str | None = None
resolution: str | None = None
#: 图片路径用像素尺寸;视频路径用 ratio+resolution。两家服务商的尺寸表达方式
#: 不同,契约同时保留两种而不强行统一——任何统一都会在某一侧失真。
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 = Field(default=None, max_length=64)
callback_url: str | None = None
upload_target: str | None = None
@model_validator(mode="after")
def require_prompt_or_input(self) -> SubmitRequest:
"""`prompt` 与 `input_url` 至少要有一个。
空内容必然被服务商拒,但那要花一次往返、还可能计入 RPM。本地先拦。
"""
if not self.prompt and not self.input_url:
raise ValueError("prompt 与 input_url 至少要有一个")
return self
def to_domain(self) -> GenerationRequest:
return GenerationRequest(
job_id=self.job_id,
media_kind=self.media_kind,
model=self.model,
prompt=self.prompt,
input_url=self.input_url,
ratio=self.ratio,
resolution=self.resolution,
width_px=self.width_px,
height_px=self.height_px,
duration_sec=self.duration_sec,
generate_audio=self.generate_audio,
watermark=self.watermark,
safety_identifier=self.safety_identifier,
callback_url=self.callback_url,
upload_target=self.upload_target,
)
class SubmitResponse(BaseModel):
provider_request_id: str
class FailureModel(BaseModel):
"""失败分类。三个字段各有明确消费方,见契约说明。"""
retry: str
user_facing: str
provider_code: str
message: str = ""
class TaskResponse(BaseModel):
"""任务当前状态。
注意 `state` 的取值集**比服务商的更小**:服务商的 `queued` 映射为 `pending`、
`expired` 归入 `failed`(它是超时,属失败的一种,重试策略由 `failure` 表达)。
"""
provider_request_id: str
state: str
progress: int
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
billing_tokens: int | None = None
failure: FailureModel | None = None
@classmethod
def of(cls, result: GenerationResult) -> TaskResponse:
return cls(
provider_request_id=result.provider_request_id,
state=result.state.value,
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,
billing_tokens=result.billing_tokens,
failure=(
FailureModel(
retry=result.failure.retry.value,
user_facing=result.failure.user_facing.value,
provider_code=result.failure.provider_code,
message=result.failure.message,
)
if result.failure
else None
),
)