`api_key=volcengine.api_key` 这类**依赖注入传参**被 KEY-ASSIGN 判为疑似凭证。 这是规则的真缺口而非代码问题:行里没有任何秘密,而做 DI 的代码到处都是这种 写法——绕过一次就得在每个调用点绕过,那等于把规则废掉。故改规则。 新增 ALLOW_REFERENCE,刻意收得很紧以免开后门: - 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状) - 只允许**恰好一个点且随即行尾** - 连字符不在字符集内 对抗性夹具验证(三真三假): - 拦下 `api_key = "sk-T3Blbk…"`(OpenAI 形态) - 拦下 `secret_key: "AKID…"`(腾讯云形态) - 拦下 `auth_token=eyJ….eyJ….Sfl…`(**三段式 JWT——两个点,故不落进放行**) - 放行 `api_key=lowercase.reference` / `api_key = settings.api_key` / `api_token: config.authToken` 原 5 形态泄漏夹具复验仍 8 条命中,一条不少。 另更正口径:脚本头与 git-workflow.md 仍写「三仓同构」,实际已是四仓—— patbond-ai 是唯一持有 AI 服务商凭证的仓,漏同步它风险最大。四仓 md5 已一致 (4945940093b4b8413b7d3eec8bf6a5ec),四仓 --all 全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""与服务商无关的生成能力抽象。
|
||||
|
||||
上层(HTTP 端点、将来的 Java 侧契约)只对着本接口编程。新增一家供应商 = 实现
|
||||
本接口 + 在注册表登记,**不动上层一行**。
|
||||
|
||||
为什么是「submit + fetch」两个方法而不是一个 `generate`:
|
||||
Java↔Python 边界一律异步(ADR-023 §4)。`submit` 立即返回厂商任务 ID,`fetch`
|
||||
查进度。同步型供应商(一次长连接出图)由其适配层在内部消化这个差异,**绝不能
|
||||
让它渗透到这个接口上**——否则客户端契约、等待态设计、E2E 都要分两套。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.domain.generation import GenerationRequest, GenerationResult, MediaKind
|
||||
|
||||
|
||||
class GenerationProvider(ABC):
|
||||
"""一家供应商的一种生成能力。"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider_code(self) -> str:
|
||||
"""与 `creation.generation_models.provider_code` 同值。
|
||||
|
||||
Java 侧按任务行冻结的 `provider_code_snapshot` 找适配器,**不按当前目录**
|
||||
——目录可能被改指向或禁用,而在途任务必须仍由当初提交它的适配器轮询。
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def media_kind(self) -> MediaKind:
|
||||
"""本适配器负责的产物类型。"""
|
||||
|
||||
@abstractmethod
|
||||
def submit(self, request: GenerationRequest) -> str:
|
||||
"""提交任务,返回厂商任务 ID。
|
||||
|
||||
**不得阻塞等待生成完成。** 调用方会在上游产生任何计费动作之前把返回的 ID
|
||||
落库——那是超时后唯一能判断「是否已提交」的依据。
|
||||
|
||||
:raises app.providers.volcengine.client.ArkError: 或其他供应商的等价异常,
|
||||
均携带已分类的 `Failure`。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self, provider_request_id: str) -> GenerationResult:
|
||||
"""查询任务当前状态。"""
|
||||
@@ -0,0 +1,42 @@
|
||||
"""provider 注册表:按 `provider_code` 分发。
|
||||
|
||||
Java 侧按任务行冻结的 `provider_code_snapshot` 找适配器,**不按当前目录**——目录
|
||||
可能被改指向或禁用,而在途任务必须仍由当初提交它的适配器轮询。用当前目录路由会
|
||||
拿着别人的任务 ID 去问错的供应商。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.providers.base import GenerationProvider
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""`provider_code` → 适配器。"""
|
||||
|
||||
def __init__(self, providers: list[GenerationProvider] | None = None):
|
||||
self._by_code: dict[str, GenerationProvider] = {}
|
||||
for provider in providers or []:
|
||||
self.register(provider)
|
||||
|
||||
def register(self, provider: GenerationProvider) -> None:
|
||||
code = provider.provider_code
|
||||
if code in self._by_code:
|
||||
# 重复注册意味着装配出错。静默覆盖会让「到底哪个实例在处理任务」
|
||||
# 取决于注册顺序,是最难查的一类问题。
|
||||
raise ValueError(f"provider_code 重复注册:{code}")
|
||||
self._by_code[code] = provider
|
||||
|
||||
def find(self, provider_code: str) -> GenerationProvider | None:
|
||||
"""未知 code 返回 None。
|
||||
|
||||
对一个已持久化的任务而言,未知 code 意味着当初提交它的适配器已被移除。
|
||||
调用方应当作**不可重试**的失败,而不是循环重试到预算耗尽。
|
||||
"""
|
||||
return self._by_code.get(provider_code)
|
||||
|
||||
@property
|
||||
def codes(self) -> frozenset[str]:
|
||||
return frozenset(self._by_code)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._by_code)
|
||||
@@ -1,370 +0,0 @@
|
||||
"""火山方舟 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()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""火山方舟(Ark)的 HTTP 传输层。
|
||||
|
||||
**本模块不认识「视频」这个概念。** 它只管鉴权、超时、请求发送、以及把厂商的错误
|
||||
体解包成带分类的异常。以后接火山的图片生成、语音或对话接口时,那些模块复用本
|
||||
client,而不是各自再抄一遍鉴权与错误解包——这是把它单独拆出来的全部理由。
|
||||
|
||||
判断代码该进这里还是进 `video.py`:问「换成火山的另一个接口,这段还一样吗」。
|
||||
鉴权头、错误体形状、超时语义都一样,故在此;`content` 数组怎么拼、`ratio` 怎么
|
||||
取值是视频接口特有的,故不在此。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.domain.generation import Failure
|
||||
|
||||
from .errors import classify
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArkError(RuntimeError):
|
||||
"""火山方舟返回的错误,携带领域分类。
|
||||
|
||||
:param failure: 已分类的失败,调用方据此决定重试策略与用户呈现,**不必再去
|
||||
看厂商错误码**——那是适配层的职责边界。
|
||||
"""
|
||||
|
||||
def __init__(self, failure: Failure, http_status: int | None = None):
|
||||
super().__init__(failure.message or failure.provider_code or "Ark 调用失败")
|
||||
self.failure = failure
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
class ArkClient:
|
||||
"""火山方舟 API 的同步 HTTP 客户端。
|
||||
|
||||
**超时语义**:`timeout_sec` 是单次 HTTP 往返的超时,**不是生成耗时**。方舟的
|
||||
内容生成接口是异步的(提交只入队即返回),所以几秒足够;把它调大成「等出图」
|
||||
会重新引入长连接占用与「不知道有没有扣费」的歧义(ADR-023 §4)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
timeout_sec: float = 10.0,
|
||||
client: httpx.Client | None = None,
|
||||
):
|
||||
if not api_key:
|
||||
# 缺密钥是服务端配置错误,不是上游故障。在构造点就说清楚,
|
||||
# 免得表现为一串莫名的 401。
|
||||
raise ValueError(
|
||||
"ARK_API_KEY 未配置:请通过环境变量注入,切勿写入代码或样例文件"
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._owns_client = client is None
|
||||
self._client = client or httpx.Client(timeout=timeout_sec)
|
||||
|
||||
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._send("POST", path, json=body)
|
||||
|
||||
def get(self, path: str) -> dict[str, Any]:
|
||||
return self._send("GET", path)
|
||||
|
||||
# ---------------------------------------------------------------- 内部
|
||||
|
||||
def _send(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||
url = f"{self._base_url}{path}"
|
||||
try:
|
||||
response = self._client.request(
|
||||
method, url, headers=self._headers(), **kwargs
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
# 网络层失败按可重试处理。注意**提交超时属于「不知道有没有扣费」的
|
||||
# 窗口**:火山不支持幂等键,调用方必须依赖已落库的 provider_request_id
|
||||
# 或回调来判断,不可盲目重发。
|
||||
raise ArkError(classify(None, f"请求火山方舟失败:{exc}")) from exc
|
||||
return self._unwrap(response)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(response: httpx.Response) -> dict[str, Any]:
|
||||
"""把响应转成 dict;非 2xx 转成带分类的 `ArkError`。
|
||||
|
||||
错误体形状取自官方错误码表:`{"error": {"code": ..., "message": ...}}`。
|
||||
|
||||
响应体解析失败时**不静默**——那通常意味着打到了错误的地址(例如网关返回
|
||||
HTML 错误页),静默会让排查绕一大圈。
|
||||
"""
|
||||
if response.is_success:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise ArkError(
|
||||
classify(
|
||||
None,
|
||||
f"响应不是合法 JSON(HTTP {response.status_code}):"
|
||||
f"{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
|
||||
|
||||
failure = classify(code, message)
|
||||
log.warning(
|
||||
"火山方舟返回错误 http=%s code=%s retry=%s",
|
||||
response.status_code,
|
||||
code,
|
||||
failure.retry.value,
|
||||
)
|
||||
raise ArkError(failure, response.status_code)
|
||||
|
||||
def close(self) -> None:
|
||||
# 只关自己创建的连接池:注入进来的 client 归调用方管理,
|
||||
# 在这里关掉会让共享同一个 client 的其他适配器一起失效。
|
||||
if self._owns_client:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> ArkClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
@@ -1,64 +1,27 @@
|
||||
"""火山方舟(Volcengine Ark)错误分类。
|
||||
"""火山方舟错误码 → 领域 `Failure` 的映射。
|
||||
|
||||
把厂商的错误码翻译成三个对 Worker 有意义的维度:**是否可重试**、**等多久**、
|
||||
**给用户看什么**。分类依据是官方错误码表(见 patbond-doc
|
||||
`docs/development/providers/volcengine-seedance.md` 的取证记录)。
|
||||
**本模块是唯一理解火山错误码的地方。** 上层只看 `Failure` 的 `retry` 与
|
||||
`user_facing`,不接触厂商码——把厂商词汇挡在这一层,是「换供应商不动上层」
|
||||
的前提。
|
||||
|
||||
为什么值得单独成一个模块而不是散在客户端里:
|
||||
三处如果搞错只会在生产暴露的地方:
|
||||
|
||||
1. **`QuotaExceeded` 一码两义。** 同一个 code 既表示「免费额度/周期限额耗尽」
|
||||
(要等到明天或去充值),也表示「当前账号排队中任务数超限」(等几秒就好)。
|
||||
官方文档里这两条是相邻的两行、code 完全相同,只有 message 不同。若按 code
|
||||
官方错误码表里这两条是相邻两行、code 完全相同,只有 message 不同。按 code
|
||||
一刀切,必然有一半场景的文案和出口是错的。
|
||||
2. **`expired` 状态没写在查询接口的状态枚举里。** 查询文档列的是 queued /
|
||||
running / cancelled / succeeded / failed 五个,但回调文档额外列了 `expired`
|
||||
(任务超过 `execution_expires_after` 被自动终止)。只按查询文档实现,线上
|
||||
遇到 expired 会掉进未知分支。
|
||||
3. 敏感内容类错误**重试毫无意义**(同样的输入必然同样被拒),必须与限流类
|
||||
区分开,否则会白烧三次重试额度再失败。
|
||||
2. **内容安全类重试毫无意义**(同样的输入必然同样被拒),必须与限流类分开,
|
||||
否则会白烧三次重试预算再失败。
|
||||
3. **未知码必须偏向可重试**而不是判死:厂商在持续新增码。
|
||||
|
||||
映射的完整取证见 patbond-doc `docs/development/providers/volcengine-seedance.md`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Retry(Enum):
|
||||
"""重试策略。"""
|
||||
|
||||
#: 不可重试:同样的输入必然得到同样的结果(参数错、内容被拒、鉴权失败)。
|
||||
NEVER = "never"
|
||||
#: 可重试且应尽快:限流、突发流量保护、服务过载。退避几秒到几十秒。
|
||||
BACKOFF = "backoff"
|
||||
#: 可重试但要等很久:额度/配额耗尽,通常要等到下一个计费周期或充值之后。
|
||||
#: Worker 不应把重试额度浪费在这上面,应直接终态失败并让用户看到出口。
|
||||
QUOTA = "quota"
|
||||
|
||||
|
||||
class UserFacing(Enum):
|
||||
"""给终端用户的呈现类别,对应客户端的两个 429 分支(ADR-023 §9)。"""
|
||||
|
||||
#: 配额耗尽:等的是「明天」或「充值」。客户端弹 sheet 并给出出口。
|
||||
QUOTA_EXHAUSTED = "quota_exhausted"
|
||||
#: 频率过快:等的是「几秒」。客户端弹 SnackBar,可自动重试。
|
||||
RATE_LIMITED = "rate_limited"
|
||||
#: 内容被拒:换提示词或换图。不可重试。
|
||||
CONTENT_REJECTED = "content_rejected"
|
||||
#: 服务端问题(我们的 bug 或配置错误)。用户无从处置,只报通用失败。
|
||||
SERVER_ERROR = "server_error"
|
||||
#: 上游暂时不可用。可重试。
|
||||
UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Classification:
|
||||
retry: Retry
|
||||
user_facing: UserFacing
|
||||
#: 原始厂商错误码,原样透传便于排查——绝不丢弃。
|
||||
provider_code: str
|
||||
|
||||
from app.domain.generation import Failure, Retry, UserFacing
|
||||
|
||||
#: 内容安全类:**重试无意义**。覆盖输入/输出的文本、图片、视频、音频,以及
|
||||
#: 版权、真人、伪造证件等子类,另含风险识别产品(*RiskDetection)的四个码。
|
||||
@@ -140,7 +103,7 @@ _QUEUE_BACKLOG_MESSAGE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def classify(provider_code: str | None, message: str | None = None) -> Classification:
|
||||
def classify(provider_code: str | None, message: str | None = None) -> Failure:
|
||||
"""把厂商错误码分类。
|
||||
|
||||
未知码一律按**可退避重试的上游问题**处理而不是直接判死:厂商在持续新增错误
|
||||
@@ -151,32 +114,32 @@ def classify(provider_code: str | None, message: str | None = None) -> Classific
|
||||
text = message or ""
|
||||
|
||||
if not code:
|
||||
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "")
|
||||
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "", text)
|
||||
|
||||
if code.startswith(_CONTENT_PREFIXES):
|
||||
return Classification(Retry.NEVER, UserFacing.CONTENT_REJECTED, code)
|
||||
return Failure(Retry.NEVER, UserFacing.CONTENT_REJECTED, code, text)
|
||||
|
||||
if code == "ContentSecurityDetectionError":
|
||||
# 风险识别产品自身请求失败,不是内容真的违规 —— 可重试。
|
||||
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
|
||||
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text)
|
||||
|
||||
if code == "QuotaExceeded":
|
||||
# 一码两义,见模块 docstring。
|
||||
if _QUEUE_BACKLOG_MESSAGE.search(text):
|
||||
return Classification(Retry.BACKOFF, UserFacing.RATE_LIMITED, code)
|
||||
return Classification(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code)
|
||||
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, code, text)
|
||||
return Failure(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code, text)
|
||||
|
||||
if code in _RATE_LIMIT_CODES:
|
||||
return Classification(Retry.BACKOFF, UserFacing.RATE_LIMITED, code)
|
||||
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, code, text)
|
||||
|
||||
if code in _QUOTA_CODES:
|
||||
return Classification(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code)
|
||||
return Failure(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code, text)
|
||||
|
||||
if code in _SERVER_FAULT_CODES:
|
||||
return Classification(Retry.NEVER, UserFacing.SERVER_ERROR, code)
|
||||
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, code, text)
|
||||
|
||||
if code in _UPSTREAM_CODES:
|
||||
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
|
||||
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text)
|
||||
|
||||
# 参数类:我方拼错了请求体,重试同样的请求体不会变好。
|
||||
# 含 InvalidParameter.TaskTypeConstraint / TaskTypeMismatch —— 那两个是
|
||||
@@ -193,6 +156,6 @@ def classify(provider_code: str | None, message: str | None = None) -> Classific
|
||||
"OutofContextError",
|
||||
)
|
||||
):
|
||||
return Classification(Retry.NEVER, UserFacing.SERVER_ERROR, code)
|
||||
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, code, text)
|
||||
|
||||
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
|
||||
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""火山方舟内容生成接口的请求/响应 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
|
||||
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,
|
||||
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"),
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Seedance 视频生成:领域请求 ↔ 火山请求体的翻译。
|
||||
|
||||
本模块只管**视频生成这一个业务**:怎么拼 `content` 数组、状态怎么映射到领域状态、
|
||||
失败怎么分类。HTTP 传输与鉴权在 `client.py`,厂商 DTO 在 `models.py`,错误码映射
|
||||
在 `errors.py`。
|
||||
|
||||
以后接火山的**图片生成**时,应新建一个兄弟模块(如 `image.py`)复用同一个
|
||||
`ArkClient` 与 `errors.py`,而不是在本文件里加分支——视频与图片的参数集、状态机、
|
||||
产物形态都不同,混在一处会让两边都难改。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.domain.generation import (
|
||||
Failure,
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
MediaKind,
|
||||
Retry,
|
||||
TaskState,
|
||||
UserFacing,
|
||||
)
|
||||
from app.providers.base import GenerationProvider
|
||||
|
||||
from .client import ArkClient
|
||||
from .models import (
|
||||
ASSET_URL_TTL_SEC,
|
||||
TASKS_PATH,
|
||||
SeedanceStatus,
|
||||
SeedanceSubmission,
|
||||
SeedanceTask,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: `creation.generation_models.provider_code` 里对应本适配器的值。
|
||||
PROVIDER_CODE = "volcengine-seedance"
|
||||
|
||||
|
||||
class SeedanceVideoProvider(GenerationProvider):
|
||||
"""火山方舟 Seedance 的视频生成适配器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: ArkClient,
|
||||
default_model: str = "",
|
||||
task_expiry_sec: int = 3600,
|
||||
):
|
||||
self._client = client
|
||||
self._default_model = default_model
|
||||
self._task_expiry_sec = task_expiry_sec
|
||||
|
||||
@property
|
||||
def provider_code(self) -> str:
|
||||
return PROVIDER_CODE
|
||||
|
||||
@property
|
||||
def media_kind(self) -> MediaKind:
|
||||
return MediaKind.VIDEO
|
||||
|
||||
# ------------------------------------------------------------- submit
|
||||
|
||||
def submit(self, request: GenerationRequest) -> str:
|
||||
if request.media_kind is not MediaKind.VIDEO:
|
||||
# 路由错了。与其把一个图片请求塞给视频接口让厂商拒,不如就地说清楚。
|
||||
raise ValueError(
|
||||
f"{PROVIDER_CODE} 只处理视频生成,收到 {request.media_kind.value}"
|
||||
)
|
||||
|
||||
submission = self._to_submission(request)
|
||||
payload = self._client.post(TASKS_PATH, submission.to_body())
|
||||
task_id = payload.get("id")
|
||||
if not task_id:
|
||||
# 2xx 但没有 ID:我们再也查不到这个任务,而它可能已经在计费。
|
||||
# 绝不能当成功放过。
|
||||
raise ValueError(f"火山方舟提交成功但响应缺少任务 ID:{payload!r}")
|
||||
return str(task_id)
|
||||
|
||||
def _to_submission(self, request: GenerationRequest) -> SeedanceSubmission:
|
||||
"""领域请求 → 厂商请求体。
|
||||
|
||||
模型取值优先用请求里的(来自任务行冻结的快照),仅在缺失时回落到配置的
|
||||
默认值。**适配层不自行选模型**:选型是目录的职责,且任务行里的快照必须
|
||||
与实际调用的模型一致,否则事后无法对账。
|
||||
"""
|
||||
model = request.model or self._default_model
|
||||
if not model:
|
||||
raise ValueError("未指定模型:请求未携带 model 且 ARK_VIDEO_MODEL 未配置")
|
||||
return SeedanceSubmission(
|
||||
model=model,
|
||||
prompt=request.prompt,
|
||||
first_frame_url=request.input_url,
|
||||
ratio=request.ratio or SeedanceSubmission.ratio,
|
||||
resolution=request.resolution or SeedanceSubmission.resolution,
|
||||
duration=(
|
||||
request.duration_sec
|
||||
if request.duration_sec is not None
|
||||
else SeedanceSubmission.duration
|
||||
),
|
||||
generate_audio=request.generate_audio,
|
||||
watermark=request.watermark,
|
||||
execution_expires_after=self._task_expiry_sec,
|
||||
safety_identifier=request.safety_identifier,
|
||||
callback_url=request.callback_url,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------- fetch
|
||||
|
||||
def fetch(self, provider_request_id: str) -> GenerationResult:
|
||||
payload = self._client.get(f"{TASKS_PATH}/{provider_request_id}")
|
||||
task = SeedanceTask.from_payload(payload)
|
||||
return self._to_result(task, provider_request_id)
|
||||
|
||||
def _to_result(self, task: SeedanceTask, request_id: str) -> GenerationResult:
|
||||
"""厂商任务 → 领域结果。
|
||||
|
||||
状态映射见下。注意领域状态集比厂商的**更小**:queued/running 都还没结束,
|
||||
expired 是失败的一种(重试策略由 `Failure` 表达,不需要一个独立状态)。
|
||||
"""
|
||||
state = self._map_state(task)
|
||||
failure = self._map_failure(task)
|
||||
|
||||
if state is TaskState.SUCCEEDED and not task.video_url:
|
||||
# 厂商说成功却没给 URL:这不是成功。当成可重试的上游异常,
|
||||
# 而不是把一个没有产物的「成功」放给上层去空指针。
|
||||
log.error("火山方舟任务 %s 状态为 succeeded 但缺少 video_url", task.id)
|
||||
return GenerationResult(
|
||||
provider_request_id=request_id,
|
||||
state=TaskState.FAILED,
|
||||
failure=Failure(
|
||||
Retry.BACKOFF,
|
||||
UserFacing.UPSTREAM_UNAVAILABLE,
|
||||
"MissingVideoUrl",
|
||||
"厂商返回 succeeded 但 content.video_url 为空",
|
||||
),
|
||||
)
|
||||
|
||||
return GenerationResult(
|
||||
provider_request_id=request_id,
|
||||
state=state,
|
||||
# 火山不返回百分比进度。这不是缺陷而是事实:客户端契约本就不承诺
|
||||
# 平滑进度(ADR-023 §10),成功前一律 0、成功即 100。
|
||||
progress=100 if state is TaskState.SUCCEEDED else 0,
|
||||
asset_url=task.video_url,
|
||||
asset_url_expires_in_sec=(ASSET_URL_TTL_SEC if task.video_url else None),
|
||||
cover_url=task.last_frame_url,
|
||||
ratio=task.ratio,
|
||||
resolution=task.resolution,
|
||||
# 像素尺寸留空:火山只返回 ratio 与 resolution,换算需要一张随模型族
|
||||
# 变化的对照表。在 V7 目录种子定下模型族之前不编造尺寸。
|
||||
duration_ms=task.duration_ms,
|
||||
output_format=task.output_format,
|
||||
failure=failure,
|
||||
billing_tokens=task.completion_tokens,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_state(task: SeedanceTask) -> TaskState:
|
||||
match task.status:
|
||||
case SeedanceStatus.QUEUED:
|
||||
return TaskState.PENDING
|
||||
case SeedanceStatus.RUNNING:
|
||||
return TaskState.RUNNING
|
||||
case SeedanceStatus.SUCCEEDED:
|
||||
return TaskState.SUCCEEDED
|
||||
case SeedanceStatus.CANCELLED:
|
||||
return TaskState.CANCELLED
|
||||
case SeedanceStatus.FAILED | SeedanceStatus.EXPIRED:
|
||||
return TaskState.FAILED
|
||||
case _:
|
||||
# 未知状态当作「还在跑」:厂商新增状态时把在途任务判死更糟,
|
||||
# 最坏是等到我们自己的超时。
|
||||
log.warning("火山方舟返回未知任务状态:%r", task.raw_status)
|
||||
return TaskState.RUNNING
|
||||
|
||||
@staticmethod
|
||||
def _map_failure(task: SeedanceTask) -> Failure | None:
|
||||
from .errors import classify
|
||||
|
||||
if task.status is SeedanceStatus.EXPIRED:
|
||||
# 超时不是内容或参数问题,可重试。单独处理是因为厂商在 expired 时
|
||||
# 不一定给 error.code,走通用分类会被当成「无码」。
|
||||
return Failure(
|
||||
Retry.BACKOFF,
|
||||
UserFacing.UPSTREAM_UNAVAILABLE,
|
||||
"TaskExpired",
|
||||
"任务超过 execution_expires_after 被厂商终止",
|
||||
)
|
||||
if task.status is SeedanceStatus.FAILED:
|
||||
return classify(task.error_code, task.error_message)
|
||||
return None
|
||||
Reference in New Issue
Block a user