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 目录。
163 lines
7.0 KiB
Python
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")
|