Repository navigation
Proposal: 接入 Fal 队列协议面,并把协议表达为显式接缝 #332
Description
Proposal: 接入 Fal 队列协议面,并把“协议”表达为显式接缝
1. Summary
网关同时提供三套协议,产品目前只接了 OpenAI 兼容面。本提案接入 Fal 队列面,并在此过程中把“协议”从 sufy.py 的条件分支提取为一个可脱网单测的接口,使此后新增一个协议是新增一个文件、而不是修改现有 adapter。
范围只到视频链路的一个任务类型:first-last-frame-to-video。其余协议面与能力留给后续提案。
2. User Stories / Motivation
| 需求 | 现状 | 所需能力 | 所在协议面 |
|---|---|---|---|
| 循环动作末帧接回首帧时左右腿互换(#197) | 交付帧取到半个步态周期,肉眼可见跳变 | first-last-frame-to-video,首尾给同一姿态即闭环 |
Fal 队列 |
| 动作只能靠提示词描述,不可控 | 同一描述反复生成结果不稳定 | motion-control |
Fal 队列 |
| 角色一致性依赖母版单图 | 换动作时身份漂移 | reference-to-video |
Fal 队列 |
三个需求指向同一个共同点:它们都不是缺算法,而是被同一个未接入的协议面挡住。GET /v1/models 只列聊天协议的模型,据它判断能力会得出“网关没有这些能力”的错误结论。
3. Current Workaround
没有可用的绕法,已经试过一次并回退:
providers/sufy.py:254 保留着一段记录——曾实现过一整套 FalQueueVideoProvider + FirstFrameUploader + 端点映射表,412 行代码、28 条测试,最终整体删除。删除理由是“从未被真实调用过”。
删除是对的,但根因不是那套实现写得不好:VideoProvider 只有 i2v(first_frame, prompt, seconds, size) -> bytes 一个方法,形状固定为“一次调用出 bytes”;Fal 面是“建单 → 轮询 → 取结果”,且鉴权头是 Key 不是 Bearer。没有承接它的接缝,所以那套代码接不进产品链路,只能躺着或删掉。
今天要接任何 Fal 面能力,只有两条路:把第三套请求形状继续塞进 sufy.py 的条件分支,或者再写一次那 412 行然后再删一次。
4. Goals
- 交付
first-last-frame-to-video,使 循环选帧取到半个步态周期,末帧接回首帧时左右腿互换(Refs #171) #197 的循环闭合有可用解法。 - 新增一个协议面的改动落在新增文件上,不修改
sufy.py与gateway/。 - 协议层可脱离网络单测。
5. Out of Scope
motion-control与reference-to-video:同一接缝落地后各自单独提案。- 厂商维度(base_url / 鉴权 / 型号目录)的拆分。
- chat 能力、
providers/image.py与providers/video.py的去留。 - 原厂 Bypass 面(
/bypass/anthropic/v1/messages等)。 - 重试、Fallback、熔断、trace:已由 [WIP] feat(gateway): 图/视频调用经进程内 Gateway 做重试、Fallback 与观测 #331 的
gateway/承担,本提案不改。
6. Proposal
6.1 Design Rule
一条规则:协议只知道字节怎么排,不发请求、不重试、不休眠。
请求的构造与响应的解析是纯函数;发请求、轮询节奏、失败处理分别归 adapter 与 gateway/。
6.2 Interface
class JobProtocol(Protocol):
"""建单 → 轮询 → 取结果。/v1/videos 与 /queue/* 差别只在路径与鉴权,形状同构。"""
def build_submit(self, req: VideoRequest) -> HttpCall: ...
def parse_submit(self, resp: HttpResponse) -> AdapterResult: ...
def build_poll(self, job_id: str) -> HttpCall: ...
def parse_poll(self, resp: HttpResponse) -> AdapterResult: ...
def build_fetch(self, job_id: str) -> HttpCall | None: ...HttpCall 是 method / path / headers / body 的纯数据结构。AdapterResult 沿用 #331 已定义的那个,不新造。
鉴权头由协议层产出,不由厂商层统一注入 —— Fal 面是 Authorization: Key {key},OpenAI 面是 Bearer,写错时的响应与“模型不存在”难以区分。
6.3 Examples as Specification
# 现状:请求形状写死在 adapter 里,按型号分支
# providers/sufy.py
if model in _IMAGE_LIST_MODELS:
body["image_list"] = [{"image": b64}]
else:
body["input_reference"] = _first_frame_datauri(first_frame, size)
job = client.post("/videos", json=body) # 路径与鉴权也写死
# 提案:协议层只产出纯数据,adapter 照着发
call = protocol.build_submit(req)
# OpenAI 面 → HttpCall(
# method="POST", path="/v1/videos",
# headers={"Authorization": "Bearer <key>"},
# body={"model": ..., "input_reference": "data:image/jpeg;base64,..."})
# Fal 面 → HttpCall(
# method="POST", path="/queue/fal-ai/veo3.1/first-last-frame-to-video",
# headers={"Authorization": "Key <key>"},
# body={"prompt": ..., "image_url": "...", "end_image_url": "..."})
# 等价性:两条面产出的 AdapterResult 形状相同,gateway/ 无需区分6.4 Boundary Cases
| 情形 | 行为 |
|---|---|
型号未在 FAMILIES 登记 |
RegistryError,建单前拒绝 |
| 同一 fallback 链上混入不同协议面 | _validate_chain 拒绝(#331 已有判据,不变) |
Fal 面的首帧字段名叫 *_url,值却可以是 base64 dataURI |
两面共用同一套首帧编码,不需要 bytes → 公网 URL 的上传器 |
| 首尾帧只给了一张 | 退回普通 i2v,不静默补一张 |
7. Error Handling
| 条件 | 行为 |
|---|---|
| 鉴权头写错(Key 与 Bearer 互换) | 401,错误信息标明协议面,不与“模型不存在”混淆 |
建单返回 2xx 但无 request_id |
INVALID_RESPONSE,不进入轮询 |
| 轮询预算耗尽 | TIMEOUT,标记可能已计费,不重复建单 |
8. Compatibility
纯新增。ImageProvider / VideoProvider 的方法签名不变,ai_engine 与业务侧零改动。现有 OpenAI 面路径行为不变,由现有测试约束。
9. Alternatives Considered
9.1 继续在 sufy.py 内加分支
改动最小,但第三套请求形状进来后,该文件同时承担三种路径、两种鉴权、三种轮询协议。已经因此删过一次 412 行。
9.2 直接用 fal-client
Fal 队列协议有官方 Python 客户端。读过源码后不采用,理由是轮询地址的拼装规则写死,与本网关的路径结构对不上:
- 域名可换。
fal_client/auth.py的FAL_RUN_HOST与FAL_QUEUE_RUN_HOST都读环境变量,默认fal.run/queue.fal.run。但两者在模块导入时求值成QUEUE_URL_FORMAT,是进程级常量,不能按调用切换。 - 路径不可换。提交走
QUEUE_URL_FORMAT + application原样拼接,查状态与取结果走f"{QUEUE_URL_FORMAT}{prefix}{app_id.owner}/{app_id.alias}/requests/{request_id}",即只取 endpoint 的前两段。前两段这条规则与本网关一致(fal-ai/kling-video/o1/image-to-video的轮询地址实测就是/queue/fal-ai/kling-video/requests/{id},四个端点同规则);不成立的是QUEUE_URL_FORMAT那一段:它在模块导入时求值成进程级常量,/queue前缀与域名都不能按调用切换,而本网关的两个协议面共用同一个进程。 - 上传另有一套。
REST_URL = "https://rest.fal.ai"是字面量,不读环境变量;走本网关时上传路径无法改指。本提案只传 URL、不用它的上传,故不构成阻塞,但也说明这个客户端假定了自己在跟 fal 官方端点说话。
结论是三步轮询自实现,协议层仍按 6.2 的接口收敛 —— 换成任何一个库都不影响那个接口。
9.3 用 LiteLLM 统一
LiteLLM 提供 video_generation / video_status / video_content,其 Router 也带 retries 与 fallbacks。但其视频 provider 覆盖 OpenAI、Azure、Gemini、Vertex 与 RunwayML,不含 Fal 队列协议,因此不适用于本网关的视频链路。
10. Testing Strategy
| 测试项 | 方法 |
|---|---|
| 协议层构造正确 | 断言 build_submit 产出的 path、headers、body 字段,不发网络 |
| 两面产出同构 | 同一 VideoRequest 分别过两个协议,断言 AdapterResult 字段集一致 |
| 鉴权头随协议面变化 | 断言 Fal 面为 Key、OpenAI 面为 Bearer |
| 循环闭合 | 首尾帧给同一姿态,断言末帧与首帧的姿态差低于既有 loop_seam 阈值 |
| 既有行为不变 | OpenAI 面现有测试全部沿用,不修改 |
11. Open Questions
原先待定的「fal-client 能否指向自建 base_url」已在 9.2 给出结论。
2026-08-24 实测补两条接口事实:
/status的COMPLETED不是成功信号。 成功与失败的任务都返回 HTTP 200 +COMPLETED;成败只在取结果那一步显形(成功 200 带video.url,失败 500 带detail)。所以本面的build_fetch必须返回真的调用,而不是像 OpenAI 面那样返回None。- 取结果时的 HTTP 400 可能表示「还没好」。 veo3.1 与 vidu 在未就绪时取结果返回 400,响应体是
{"status":"IN_PROGRESS", ...}。按 400 一律判客户端错会把还在跑的任务当成失败,而单据已建、可能已计费。
仍待验:各端点 duration 的取值形态(5 / "5" / "5s" 三种,四个端点未逐个实测)。猜错就是一次已计费的 400,故第三步接线前必须补测。
13. 实施计划
三步,每步一个 scoped issue 由 PR 关闭,本 Issue 只当路线图。
| 步 | 内容 | 落点 | 状态 |
|---|---|---|---|
| 1 | 协议接缝提取,OpenAI 面行为不变 | #561 ← PR #562 | CI 全绿,已 approve |
| 2 | Fal 队列面实现 + 脱网单测 | #568 ← PR #569 | CI 全绿,已 approve |
| 3 | 接进型号链路,主力换 kling-3.0-turbo | 待开 scoped issue | 已实现,未提 |
第 3 步是唯一有产品影响的一步,回退方式是改回 AI_VIDEO_MODEL 环境变量,不需要回滚代码。
12. Summary of Changes
| 位置 | 改动 |
|---|---|
providers/protocol/(新增) |
JobProtocol 接口 + OpenAI 面与 Fal 面两个实现 |
gateway/registry.py |
FAMILIES 取值由“请求形状”改为“协议面 + 能力”,键不变 |
providers/sufy.py |
请求构造与响应解析迁出,行为不变 |
| 测试 | 协议层脱网单测;OpenAI 面既有测试不变 |
13. 08-21 实测:动机从「补能力」变成「换现役型号」
同一只鸟、同一张首帧(#511 修复后产出:中灰底、主体占幅 42.5%)、同一条产品提示词,横评七个型号。尺度漂移=主体高占画面高的最大值相对首帧的增幅:
| 型号 | 时长 | 尺度漂移 | 上游官方价折算 | 人工判定 |
|---|---|---|---|---|
| seedance-2.0 | 5s | +0% | ≈¥4.7 | 第二好 |
| kling-3.0-turbo | 3s | +2% | ≈¥2.4 | 最好 |
| kling-v3 std | 3s | +3% | ≈¥1.8 | 把「飞」做成了走路 |
| kling-v2-5-turbo(现役) | 5s | +6% | ≈¥2.2 | 一般 |
| veo 3.1 | 4s | +7% | ≈¥5.7 | 强,但约 3 倍价 |
| veo 3.1 | 8s | +21% | ≈¥11.4 | 时长越长越放飞 |
| kling-v2-6 | 5s | +27% | ≈¥2.2 | 出杂物 |
这给本提案加了一个比原动机更硬的理由:原动机是三个缺失能力(first-last-frame / motion-control / reference-to-video)。现在还多一条 —— 人工判定最好的型号 kling-3.0-turbo 只存在于队列面。POST /v1/videos 的 model 枚举只有 kling-v3-omni / kling-video-o1 / sora*;3.0-turbo 只在 queue/fal-ai/kling-video/v3/turbo/{mode}/image-to-video。换现役型号这件事本身就必须先有这条接缝。
13.1 时长按动作复杂度定,不写死 5 秒
漂移随时长单调上升(veo 4s +7% → 8s +21%;kling 3s 只 +2%)。所以时长是质量参数不只是成本参数:简单循环动作(走路、待机、飞行)3 秒足够且最稳,复杂一次性动作(攻击、跳跃)再加长。当前 i2v(..., seconds=5) 写死。
时长下限按型号不同:v2.5-turbo / v2.6 最短 5s;o1 / v3 std / 3.0-turbo 支持 3s。3s@24fps = 73 帧,抽 32 帧仍够,但这条要有断言。
13.2 已定:主力换 kling-3.0-turbo,v2-5-turbo 降为 fallback
这是产品决定,不是本提案的备选项。 现役 kling-v2-5-turbo 降为 fallback,主力换成
kling-3.0-turbo;实验的主力臂同步换成后者,此后新的横评以它为基线,v2-5-turbo 只作对照。
所以本提案不再是「补三个缺失能力」的可选增强 —— 不做它,主力模型就换不了,因为
3.0-turbo 只存在于队列面。
13.3 模型级 fallback 是新东西,现有 fallback 不覆盖它
gateway/routes.py 的 routes_from_settings 按 base_url + api_key 展开路由,fallback 是「换域名/换 key」这一维;模型维度不在其中。而这里要的是「3.0-turbo 失败时退回 v2.5-turbo」—— 换协议面、换鉴权头、换请求形状。本提案的 JobProtocol 接缝正好承接它,但「按模型排候选」这一层要显式定义,不能默认沿用路由 fallback。
13.4 两个待验(不猜)
- 首帧传法:队列面吃不吃 dataURI 没验出来。只给
image_url不给prompt时,合法 dataURI 与非法裸串返回同一个「prompt 缺失」,说明 image 在 prompt 之后才校验。若只吃公网 URL,则调用前要上传对象存储(产品已有该能力)。定这条要一次真实提交 - 音频白付:3.0-turbo 的
billing_type_description是「可灵3.0Turbo 720P有声视频」,即使请求里传了generate_audio: false。我们生成完丢音轨,这笔是白付的;有无声档能省则省,未查到
横评产物与逐条读数在本地归档(八条原视频 + 共同首帧 + 对照条),不入仓。
Activity
minorcell commentedon Aug 17, 2026
@johnnyzhang-eng
[-]proposal: provider 层按「协议 / 厂商 / 统一生成 API」重新划分[/-][+]Proposal: 接入 Fal 队列协议面,并把协议表达为显式接缝[/+]johnnyzhang-eng commentedon Aug 19, 2026
生态这条查过了,结论写进 9.2:fal 官方 Python 客户端不采用,不是因为想自己写,而是它查状态与取结果的地址写死成 owner/alias/requests/id —— AppId 拆出的 path 段在重拼时被丢掉,本网关的 /queue 前缀和末段一起没了;域名倒是能用 FAL_RUN_HOST 换,但那是模块导入时求值的进程级常量。LiteLLM 的视频 provider 不含 Fal 队列面,同样不适用。废话已经删掉,Open Questions 现在是空的。
johnnyzhang-eng commentedon Aug 21, 2026
主力模型定了换 kling-3.0-turbo、v2-5-turbo 降为 fallback,实验主力臂同步换。它只在队列面,所以这条接缝从可选增强变成了换模型的前置条件。
johnnyzhang-eng commentedon Aug 23, 2026
第一步(协议接缝提取,OpenAI 面行为不变)已提 #562,scoped 到 #561。实现中两处偏离本提案的签名写在 PR 正文里,待确认后回写这里。
johnnyzhang-eng commentedon Aug 23, 2026
第 11 节那条待验有答案了,实测:队列面的字段名是 start_image_url 不是 image_url,它接受 base64 dataURI 并且真的会用——喂一张 1280x720 纯品红图配「a bird flying」的提示词,出来的视频首帧平均 RGB 是 255,1,201,不是鸟。所以 adapter 不需要做 bytes 转公网 URL 那一步。另一条实测:这个面的 /status 对成功和失败都返回 COMPLETED,成败只能从取结果那一步看(失败 HTTP 500 带 detail,成功 200 带 video.url),所以 Fal 面的 build_fetch 必须返回真的调用而不是 None。
johnnyzhang-eng commentedon Aug 24, 2026
08-17 提的两点已回:生态调研写进 9.2 节(fal 官方客户端不采用的三条实测理由、LiteLLM 的视频 provider 不含 Fal 队列面),废话也删了。之后情况变了——主力模型定为 kling-3.0-turbo,而它只存在于队列面,这条接缝从可选增强变成了换模型的前置条件;正文新增第 13 节写了三步的落点与回退方式,其中前两步的 PR 已 approve、CI 全绿。想请你重新看一下是否还维持 Proposal-NoPlan。
feat(providers): 新增 FAL 队列协议面
feat(providers): 新增 FAL 队列协议面
feat(providers): 新增 Fal 队列协议面 (#569)