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 目录。
143 lines
5.1 KiB
Python
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
|
|
),
|
|
)
|