Files
patbond-ai/app/providers/volcengine/models.py
T
lixi fd23dfc0b9
CI / ai-gates (push) Successful in 33s
feat(ai): 内部契约 openapi-internal.yaml + 像素对照表(127 测试)
按用户拍板的四点定型 Java↔Python 的 JSON 契约。

**① 字段命名 snake_case**(与对客户端的 camelCase 契约不同,有意为之):本服务是
Python,snake_case 使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需
在这一个 Feign 客户端上配置命名策略。用 camelCase 反而要在两端都加转换。

**② 两类失败分开**,判别字段是 `error.kind` 而非 HTTP 状态码:
- `service`(Java↔Python):unauthenticated / invalid_request / task_not_found /
  provider_unavailable / internal_error。属我方 bug 或配置问题。
- `provider`(Python↔服务商):非 2xx + 完整分类(retry / user_facing /
  provider_code / message),字段与 Failure 一致,故 Java 侧可复用同一段处置
  逻辑,不必为「提交时被限流」和「任务失败于限流」写两套分支。
- **任务失败是第三类,走 200 + state=failed + failure**:查询一个失败的任务,
  HTTP 调用本身是成功的。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。

**③ 像素对照表已实现**——此处需更正一个理解:该表**不需要查询任何东西、也不依赖
存储方案**。它是纯静态数据(官方文档「不同宽高比对应的宽高像素值」一节),厂商
查询响应已返回 ratio 与 resolution,查表即可,无需下载产物或解码。故
widthPx/heightPx 在 AI 视频产物上可零成本填真值(ADR-023 §11)。

实现上一个关键观察:**2.5 与 2.0 的 24 格里只有两格不同**(480p 的 16:9 与
9:16),其余完全一致。故写成「共享表 + 两格覆盖」,而非抄两份 24 行——后者每次
改动要改两处,迟早只改一处。另 4k 六格仅 2.0 有。
查不到时返回 None 而不猜(ratio=adaptive、未知模型族、该模型不支持的组合三种
情形):一个错的尺寸会让客户端按错误比例渲染,且看不出是错的。
连带给 SeedanceTask 补解析响应里的 model 字段——同一适配器实例可能同时处理两个
模型族的任务,按响应里的实际模型定族才对。

**④ 路径参数用服务商任务 ID**,并在契约里写明原因:本服务不持有状态,唯一能用来
问服务商的就是这个 ID;用 job_id 就得存一份映射,「无状态」不再成立,且会多出
一个「Java 有 ID 但查不到任何东西」的中间态。你指出的那点完全对——submit 会阻塞
一次到服务商的短往返才能拿到 ID,但服务商的提交接口本身是异步的(只入队即返回),
所以这一跳很快。契约里也如实登记了那个**无法消除的窗口**:该调用超时时 Java 拿
不到 ID 而服务商可能已创建任务,无幂等键可用,只能由 max_attempts 兜住。

测试 123 → 127:旧的「像素刻意留空」断言已作废,换成断言真实换算,并覆盖两族
差异的那两格、4k 仅 2.0、adaptive 与未知模型族的留空回退。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:26:49 +08:00

192 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""火山方舟内容生成接口的请求/响应 DTO。
只放**厂商词汇**:字段名、取值、以及厂商文档里那些需要解释的怪异之处。领域概念
在 `app/domain/generation.py`,两者的翻译在 `video.py`。
这层单独存在的理由:厂商字段名与我们的领域名不一致(`framespersecond` vs `fps`、
`content.video_url` vs `asset_url`),且厂商有若干**文档自身矛盾或反直觉**之处
需要就地注明。把这些注释和翻译混进业务代码,业务逻辑就会被厂商细节淹没。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any
#: 官方 API base URL(北京区)。厂商事实收在厂商包内——放到配置里会让「厂商换
#: 域名」需要改两处,而其中一处还在与厂商无关的层里。
DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
#: 内容生成任务的路径(提交与查询共用前缀)。
TASKS_PATH = "/contents/generations/tasks"
#: 产物 URL 的有效期。厂商文档明示 24 小时(Seedance 2.5 另有 100 次下载上限)。
#: **产物必须在此时限内转存**——把厂商 URL 长期存为 external_url 会变死链。
ASSET_URL_TTL_SEC = 24 * 3600
class SeedanceStatus(Enum):
"""厂商任务状态。
**六个取值,而查询接口文档只列了五个。** `expired` 仅见于创建接口的
`callback_url` 一节(任务超过 `execution_expires_after` 被自动终止)。只按
查询文档实现,线上遇到超时任务会掉进未知分支。
"""
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
#: 查询接口文档未列出,来自创建接口的回调状态说明。
EXPIRED = "expired"
@classmethod
def parse(cls, value: str | None) -> SeedanceStatus | None:
"""未知状态返回 None 而非抛异常。
厂商新增状态时抛异常会让所有在途任务一起失败;返回 None 让上层当成
「还没结束」继续轮询,最坏是等到我们自己的超时。
"""
for status in cls:
if status.value == value:
return status
return None
@dataclass(frozen=True)
class SeedanceSubmission:
"""提交视频生成任务的请求体。
刻意只暴露 Patbond 会用到的参数。厂商参数远多于此(全模态参考、样片任务、
priority、service_tier…),**多暴露一个就多一处要在契约、客户端、文档三处
同步维护的地方**,等真用到再加。
"""
model: str
prompt: str | None = None
#: 首帧图片 URL。给了即图生视频,不给即文生视频(参考图选填,决策 A2)。
first_frame_url: str | None = None
#: `adaptive` 让模型自适应。注意 Seedance 2.5 在首帧/首尾帧、视频编辑、
#: 视频延长任务中**只支持 adaptive**,指定具体比例会被拒。
ratio: str = "adaptive"
resolution: str = "720p"
#: 秒。`-1` 交给模型在有效范围内自选(2.5 为 [4,30],2.0 系列为 [4,15])。
duration: int = 5
generate_audio: bool = True
watermark: bool = False
#: 返回尾帧图。**默认开启**:成本为零,且可直接充当视频封面,省掉抽帧。
return_last_frame: bool = True
#: 厂商取值范围 [3600, 259200],默认 48 小时对交互式应用过长。
execution_expires_after: int = 3600
#: 终端用户哈希标识,供厂商做滥用检测。**必须是哈希,不可传明文用户 ID**。
safety_identifier: str | None = None
callback_url: str | None = None
def to_body(self) -> dict[str, Any]:
content: list[dict[str, Any]] = []
if self.prompt:
content.append({"type": "text", "text": self.prompt})
if self.first_frame_url:
content.append(
{
"type": "image_url",
"image_url": {"url": self.first_frame_url},
# role 省略时厂商按 first_frame 处理;显式写出以免依赖默认行为。
"role": "first_frame",
}
)
if not content:
# 空内容必然被厂商拒,但那要花一次往返(还可能计入 RPM)。本地先拦。
raise ValueError("提交内容为空:prompt 与 first_frame_url 至少要有一个")
body: dict[str, Any] = {
"model": self.model,
"content": content,
"ratio": self.ratio,
"resolution": self.resolution,
"duration": self.duration,
"generate_audio": self.generate_audio,
"watermark": self.watermark,
"return_last_frame": self.return_last_frame,
"execution_expires_after": self.execution_expires_after,
}
if self.safety_identifier:
body["safety_identifier"] = self.safety_identifier
if self.callback_url:
body["callback_url"] = self.callback_url
return body
@dataclass(frozen=True)
class SeedanceTask:
"""查询接口返回的任务。
:param duration_sec: 厂商返回的时长**是向下取整的约数**。官方公式:
`duration = 实际总帧数 / 24` 向下取整——133 帧实际 5.54 秒会返回 5。
:param frames: 与 `duration_sec` **只会返回其中一个**(提交时指定了 frames
才返回 frames)。故 `duration_ms` 优先按帧算。
"""
id: str
status: SeedanceStatus | None
raw_status: str | None
#: 任务实际使用的模型(形如 `doubao-seedance-2-0-260128`)。用于按模型族
#: 换算像素宽高——同一适配器实例可能同时处理两个模型族的任务。
model: str | None = None
video_url: str | None = None
last_frame_url: str | None = None
duration_sec: int | None = None
frames: int | None = None
fps: int | None = None
ratio: str | None = None
resolution: str | None = None
output_format: str | None = None
error_code: str | None = None
error_message: str | None = None
completion_tokens: int | None = None
created_at: int | None = None
updated_at: int | None = None
@property
def duration_ms(self) -> int | None:
"""尽可能精确的时长毫秒数。
有 `frames` + `fps` 时按帧算(精确);否则退回厂商的整数秒(向下取整的
约数,最多可能短 1 秒)。两者都缺时返回 None,而不是编一个 0 出来。
"""
if self.frames is not None and self.fps:
return round(self.frames / self.fps * 1000)
if self.duration_sec is not None:
return self.duration_sec * 1000
return None
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> SeedanceTask:
content = payload.get("content") or {}
error = payload.get("error") or {}
usage = payload.get("usage") or {}
raw_status = payload.get("status")
return cls(
id=str(payload.get("id") or ""),
status=SeedanceStatus.parse(raw_status),
raw_status=raw_status,
model=payload.get("model"),
video_url=content.get("video_url"),
last_frame_url=content.get("last_frame_url"),
duration_sec=payload.get("duration"),
frames=payload.get("frames"),
# 厂商字段名是 framespersecond(无下划线),不是 fps。
fps=payload.get("framespersecond"),
ratio=payload.get("ratio"),
resolution=payload.get("resolution"),
output_format=payload.get("output_format"),
error_code=error.get("code"),
error_message=error.get("message"),
# 视频生成不统计输入 token,故 total == completion,取其一即可。
completion_tokens=usage.get("completion_tokens"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
)