Files
patbond-ai/app/providers/volcengine/models.py
T
lixi 4762358f54
CI / ai-gates (push) Failing after 29s
fix(security): 凭证扫描放行依赖注入传参 + 四仓口径更正
`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>
2026-09-20 12:18:18 +08:00

188 lines
7.7 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
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"),
)