`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,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"),
|
||||
)
|
||||
Reference in New Issue
Block a user