4762358f54
CI / ai-gates (push) Failing after 29s
`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>
188 lines
7.7 KiB
Python
188 lines
7.7 KiB
Python
"""火山方舟内容生成接口的请求/响应 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"),
|
||
)
|