Files
patbond-ai/app/providers/seedance.py
T
lixi c6e8dddc8d
CI / ai-gates (push) Successful in 31s
feat(seedance): 火山方舟视频生成客户端与错误分类(58 测试,全离线)
按用户提供的三份官方文档适配 Seedance 视频生成:提交
(POST /contents/generations/tasks)与查询(GET .../tasks/{id})。
取证记录见 patbond-doc docs/development/providers/volcengine-seedance.md。

**开工前问的五项,两项与原假设不同:**

1. **不支持幂等键**。请求参数全集里没有任何幂等字段。故 ADR-023 §4 设想的
   「把我方 idempotency_key 透传为厂商幂等头」对本供应商不成立,「提交超时后
   不知道有没有扣费」只能由 max_attempts 兜住——如实登记,不假装解决。
   两点减轻:提交是异步的(只入队即返回,超时概率低);且厂商支持 callback_url,
   孤儿任务会通过回调把 ID 送回来,故回调在此供应商上还是**孤儿回收通道**,
   不只是延迟优化——这是 ADR-023 §4「回调仅预留」之外的新论据,值得复评。
2. **产物是有效期 24 小时的 URL**(2.5 另有 100 次下载上限)。因此厂商 URL
   **不能当存储用**:存进 media.assets.external_url 会在 24h 后变死链。
   **这对挂起中的存储决策是硬约束**——无论选本地还是云,都必须在 24 小时内
   把文件复制到我们自己的存储。

其余三项:查询接口只保留 7 天记录;无明确频率数值,但官方 SDK 示例用 10s 轮询
(我们的 3s/10s/30s 阶梯与之兼容)、客户端截止 30 分钟(建议据此把 ADR-023 §10
的视频客户端超时从 15~20 分钟对齐到 30 分钟);execution_expires_after 范围
[3600, 259200],实现取下限 1 小时(厂商默认 48h 对交互式应用过长,且无法更短)。

**两个文档自身的问题,实现按宽的那份:**

- **状态枚举不一致**:查询文档列 5 个(queued/running/cancelled/succeeded/
  failed),创建文档的回调一节额外列 **expired**。只按查询文档实现会让超时
  任务掉进未知分支。按 6 个实现,expired 视为**可重试**失败(是超时不是内容
  问题),且厂商 expired 时不一定给 error.code 故单独分类。
- **QuotaExceeded 一码两义**:同一 code 既表示「排队任务数超限」(等几秒)
  又表示「额度耗尽」(等到明天或充值)。按 code 一刀切必然有一半场景的文案
  和出口是错的;只能按 message 判别,两条用例都在测试里钉住。

**顺带拿到两个免费收益**:last_frame_url 可直接作视频封面(iteration-4/03 R13
把缩略图列为视频成本项,实际免费);查询返回 ratio/resolution/framespersecond/
duration,**Worker 写产物时维度与时长都在手里、无需解码**——widthPx/heightPx
债在 AI 产物上可零成本修掉。

一处精度陷阱已处理:返回的 duration 是「帧数/24 向下取整」的约数(133 帧实际
5.54s 返回 5),且 duration 与 frames 只返回其一。故优先按 frames/fps 精确换算,
只有 duration 时退回整数秒并标注为约数,缺失时返回 None 而非编 0。

取消语义已查明:厂商**只支持取消 queued 状态**的任务,故我方取消已 running
的任务无法向厂商传导,**仍会计费**。ADR-023 §6 关于取消的说明据此可定稿。

测试 58 项(错误分类 32 + 客户端 26),全部离线用 httpx MockTransport,
夹具响应体逐字取自官方文档示例以免字段名对不上。

过程中防泄漏钩子拦下了测试夹具里的 `api_key="test-key"`——规则是对的(这正是
真实密钥最常见的泄漏形态),故改用 ALLOW 清单认可的占位词,未放宽规则。

未实现:产物转存(阻塞于存储决策)、HTTP 服务端点与 openapi-internal.yaml。

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

371 lines
15 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.
"""火山方舟 Seedance 视频生成客户端。
只做两件事:**提交任务**(`POST /contents/generations/tasks`)与**查询任务**
(`GET /contents/generations/tasks/{id}`)。产物的转存不在这里——见
`SeedanceTask.video_url` 的说明。
## 厂商接口的五个关键事实(逐条取自官方文档,详见 patbond-doc
`docs/development/providers/volcengine-seedance.md`)
1. **没有幂等键。** 请求参数里没有任何幂等相关字段(有的是 model / content /
ratio / duration / generate_audio / watermark / output_format /
return_last_frame / callback_url / execution_expires_after / priority /
safety_identifier)。因此「提交超时后不知道有没有扣费」这个窗口**无法靠厂商
的幂等头消除**,只能由 `max_attempts` 兜住。这与原先的乐观假设不同,已如实
登记而非假装解决。
缓解手段有一个:本接口是**异步**的(提交只入队,立即返回任务 ID),所以提交
本身很快、超时概率低;且厂商支持 `callback_url`,一个提交超时但实际已创建的
任务会通过回调把 ID 送回来,可作为孤儿任务的回收通道。
2. **提交是异步的**:返回 `{"id": ...}`,随后靠查询接口拿状态。这正好与
「Java↔Python 边界一律异步」的既定设计吻合,无需在本服务内部做后台任务承载。
3. **产物是有效期 24 小时的 URL**(Seedance 2.5 另有 100 次下载上限)。
**因此产物必须转存**——把厂商 URL 直接存进 `media.assets.external_url`
会在 24 小时后变成死链。这一条对「本地存储还是云存储」的决策是硬约束:
无论选哪个,都必须在 24 小时内把文件复制到我们自己的存储。
4. **任务记录只保留 7 天**,`execution_expires_after` 取值范围 `[3600, 259200]`
秒(默认 48 小时)。超时任务被标记为 `expired`。
5. **限流与配额是两族不同的 429**,映射见 `errors.py`。
## 一个文档自身的矛盾,必须按宽的那份实现
查询接口文档列的状态是 queued / running / cancelled / succeeded / failed 五个,
但创建接口的 `callback_url` 一节额外列了 **`expired`**。只按查询文档实现,线上
遇到超时任务会掉进未知分支。这里按六个状态实现,并把 `expired` 当作**可重试的
失败**(是超时而非内容问题)。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Any
import httpx
from .errors import Classification, Retry, UserFacing, classify
log = logging.getLogger(__name__)
#: 官方 base URL(北京区)。
DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
_TASKS_PATH = "/contents/generations/tasks"
class TaskStatus(Enum):
"""厂商任务状态。
六个取值:查询文档给了五个,`expired` 来自创建文档的回调说明一节。
"""
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
#: 任务在排队或运行中超过 `execution_expires_after` 被自动终止。
#: **查询接口文档未列出此值**,仅见于创建接口的回调状态说明。
EXPIRED = "expired"
@classmethod
def parse(cls, value: str | None) -> TaskStatus | None:
"""未知状态返回 None 而不是抛异常。
厂商新增状态时,抛异常会让所有在途任务一起失败;返回 None 让调用方能把
它当成「还没结束」继续轮询,最坏是等到我们自己的超时。
"""
for status in cls:
if status.value == value:
return status
return None
@property
def is_terminal(self) -> bool:
return self in {
TaskStatus.SUCCEEDED,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
TaskStatus.EXPIRED,
}
@dataclass(frozen=True)
class SeedanceTask:
"""查询接口返回的任务。
:param video_url: 生成视频的 URL,**有效期仅 24 小时**。本服务不负责转存,
但调用方必须在有效期内完成转存——把它长期存为 `external_url` 会变死链。
:param last_frame_url: 尾帧图像(png,尺寸与视频一致,无水印),仅在提交时
设置 `return_last_frame=true` 才返回。可直接用作**视频封面图**,省掉
我们自己抽帧。
:param duration_sec: 厂商返回的时长**是向下取整的约数**:官方给的公式是
`duration = 实际总帧数 / 24` 向下取整,例如 133 帧实际 5.54 秒会返回 5。
因此据此换算出的 `duration_ms` 最多可能短 1 秒左右,不可当精确值用。
:param frames: 与 `duration_sec` **只会返回其中一个**(提交时指定了 frames
才返回 frames)。两者都在时以 frames 为准。
"""
id: str
status: TaskStatus | None
raw_status: str | 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` 时按帧算(精确);否则退回厂商返回的整数秒
(**向下取整的约数**,见类 docstring)。两者都缺则返回 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
@property
def failure(self) -> Classification | None:
"""失败任务的分类;非失败返回 None。
`expired` 单独处理:它是超时,不是内容或参数问题,因此可重试——而厂商
在 expired 时不一定给 `error.code`,若走通用分类会被当成「无码」处理。
"""
if self.status is TaskStatus.EXPIRED:
return Classification(
Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "TaskExpired"
)
if self.status is TaskStatus.FAILED:
return classify(self.error_code, self.error_message)
return None
@dataclass
class SeedanceSubmission:
"""一次提交所需的全部参数。
刻意不把厂商的全部参数都暴露出来——只暴露 Patbond 会用到的。多暴露一个参数
就多一处需要在契约、客户端、文档三处同步维护的地方。
"""
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` 交给模型在有效范围内自选。
duration: int = 5
generate_audio: bool = True
watermark: bool = False
#: 返回尾帧图,用作视频封面。默认开启——成本为零,省掉我们自己抽帧。
return_last_frame: bool = True
#: 任务超时阈值(秒)。取值范围 [3600, 259200]。**默认 48 小时对交互式应用
#: 过长**,故这里取下限 1 小时,避免被放弃的任务在厂商侧挂两天。
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:
# role 省略时厂商按 first_frame 处理;显式写出以免依赖默认行为。
content.append(
{
"type": "image_url",
"image_url": {"url": self.first_frame_url},
"role": "first_frame",
}
)
if not content:
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
class SeedanceError(RuntimeError):
"""厂商返回的错误,带分类。"""
def __init__(
self,
classification: Classification,
message: str,
http_status: int | None = None,
):
super().__init__(message)
self.classification = classification
self.http_status = http_status
class SeedanceClient:
"""Seedance 的同步 HTTP 客户端。
**超时设置的含义**:这里的 timeout 是「提交/查询这一次 HTTP 往返」的超时,
**不是生成耗时**。提交接口是异步的(只入队),所以几秒足够;把它调大成
「等出图」是错的,会重新引入长连接占用与计费歧义。
"""
def __init__(
self,
api_key: str,
base_url: str = DEFAULT_BASE_URL,
timeout: float = 10.0,
client: httpx.Client | None = None,
):
if not api_key:
raise ValueError("ARK_API_KEY 未配置")
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._client = client or httpx.Client(timeout=timeout)
def submit(self, submission: SeedanceSubmission) -> str:
"""提交任务,返回厂商任务 ID。
调用方必须在**上游产生任何计费动作之前**把这个 ID 落库——那是超时后
唯一能判断「是否已提交」的依据。本接口异步,返回即代表已入队。
"""
response = self._post(_TASKS_PATH, submission.to_body())
task_id = response.get("id")
if not task_id:
raise SeedanceError(
classify(None),
f"提交成功但响应缺少任务 ID:{response!r}",
)
return str(task_id)
def get(self, task_id: str) -> SeedanceTask:
"""查询任务。
注意厂商只保留最近 7 天的任务记录;更早的任务查询会 404。
"""
payload = self._get(f"{_TASKS_PATH}/{task_id}")
return self._parse_task(payload)
# ---------------------------------------------------------------- 内部
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
try:
response = self._client.post(
f"{self._base_url}{path}", json=body, headers=self._headers()
)
except httpx.HTTPError as exc:
# 网络层失败:可重试。注意提交超时属于「不知道有没有扣费」的窗口,
# 调用方须依赖已落库的 provider_request_id 或回调来判断,不可盲目重发。
raise SeedanceError(classify(None), f"请求火山方舟失败:{exc}") from exc
return self._unwrap(response)
def _get(self, path: str) -> dict[str, Any]:
try:
response = self._client.get(
f"{self._base_url}{path}", headers=self._headers()
)
except httpx.HTTPError as exc:
raise SeedanceError(classify(None), f"查询火山方舟失败:{exc}") from exc
return self._unwrap(response)
@staticmethod
def _unwrap(response: httpx.Response) -> dict[str, Any]:
"""把 HTTP 响应转成 dict,非 2xx 转成带分类的 SeedanceError。
错误体形态取自官方错误码表:`{"error": {"code": ..., "message": ...}}`。
响应体解析失败时不静默——那通常意味着我们打到了错误的地址(比如把
网关的 HTML 错误页当成了 JSON)。
"""
if response.is_success:
try:
return response.json()
except ValueError as exc:
raise SeedanceError(
classify(None),
f"响应不是合法 JSON(HTTP {response.status_code}):{response.text[:200]}",
response.status_code,
) from exc
code: str | None = None
message = response.text[:500]
try:
error = response.json().get("error") or {}
code = error.get("code")
message = error.get("message") or message
except ValueError:
pass
classification = classify(code, message)
log.warning(
"火山方舟返回错误 http=%s code=%s retry=%s",
response.status_code,
code,
classification.retry.value,
)
raise SeedanceError(classification, message, response.status_code)
@staticmethod
def _parse_task(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")
status = TaskStatus.parse(raw_status)
if status is None and raw_status is not None:
# 不抛异常:新状态让调用方按「未结束」继续轮询,最坏等到自己的超时。
log.warning("火山方舟返回未知任务状态:%r", raw_status)
return SeedanceTask(
id=str(payload.get("id") or ""),
status=status,
raw_status=raw_status,
video_url=content.get("video_url"),
last_frame_url=content.get("last_frame_url"),
duration_sec=payload.get("duration"),
frames=payload.get("frames"),
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"),
completion_tokens=usage.get("completion_tokens"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
)
def close(self) -> None:
self._client.close()