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
5. Out of Scope
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。我们生成完丢音轨,这笔是白付的;有无声档能省则省,未查到
横评产物与逐条读数在本地归档(八条原视频 + 共同首帧 + 对照条),不入仓。
Refs #192
Refs #197
Refs #331
Proposal: 接入 Fal 队列协议面,并把“协议”表达为显式接缝
1. Summary
网关同时提供三套协议,产品目前只接了 OpenAI 兼容面。本提案接入 Fal 队列面,并在此过程中把“协议”从
sufy.py的条件分支提取为一个可脱网单测的接口,使此后新增一个协议是新增一个文件、而不是修改现有 adapter。范围只到视频链路的一个任务类型:
first-last-frame-to-video。其余协议面与能力留给后续提案。2. User Stories / Motivation
first-last-frame-to-video,首尾给同一姿态即闭环motion-controlreference-to-video三个需求指向同一个共同点:它们都不是缺算法,而是被同一个未接入的协议面挡住。
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:同一接缝落地后各自单独提案。providers/image.py与providers/video.py的去留。/bypass/anthropic/v1/messages等)。gateway/承担,本提案不改。6. Proposal
6.1 Design Rule
一条规则:协议只知道字节怎么排,不发请求、不重试、不休眠。
请求的构造与响应的解析是纯函数;发请求、轮询节奏、失败处理分别归 adapter 与
gateway/。6.2 Interface
HttpCall是 method / path / headers / body 的纯数据结构。AdapterResult沿用 #331 已定义的那个,不新造。鉴权头由协议层产出,不由厂商层统一注入 —— Fal 面是
Authorization: Key {key},OpenAI 面是Bearer,写错时的响应与“模型不存在”难以区分。6.3 Examples as Specification
6.4 Boundary Cases
FAMILIES登记RegistryError,建单前拒绝_validate_chain拒绝(#331 已有判据,不变)*_url,值却可以是 base64 dataURI7. Error Handling
request_idINVALID_RESPONSE,不进入轮询TIMEOUT,标记可能已计费,不重复建单8. Compatibility
纯新增。
ImageProvider/VideoProvider的方法签名不变,ai_engine与业务侧零改动。现有 OpenAI 面路径行为不变,由现有测试约束。9. Alternatives Considered
9.1 继续在
sufy.py内加分支改动最小,但第三套请求形状进来后,该文件同时承担三种路径、两种鉴权、三种轮询协议。已经因此删过一次 412 行。
9.2 直接用
fal-clientFal 队列协议有官方 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字段集一致Key、OpenAI 面为Bearerloop_seam阈值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。{"status":"IN_PROGRESS", ...}。按 400 一律判客户端错会把还在跑的任务当成失败,而单据已建、可能已计费。仍待验:各端点
duration的取值形态(5/"5"/"5s"三种,四个端点未逐个实测)。猜错就是一次已计费的 400,故第三步接线前必须补测。13. 实施计划
三步,每步一个 scoped issue 由 PR 关闭,本 Issue 只当路线图。
第 3 步是唯一有产品影响的一步,回退方式是改回
AI_VIDEO_MODEL环境变量,不需要回滚代码。12. Summary of Changes
providers/protocol/(新增)JobProtocol接口 + OpenAI 面与 Fal 面两个实现gateway/registry.pyFAMILIES取值由“请求形状”改为“协议面 + 能力”,键不变providers/sufy.py13. 08-21 实测:动机从「补能力」变成「换现役型号」
同一只鸟、同一张首帧(#511 修复后产出:中灰底、主体占幅 42.5%)、同一条产品提示词,横评七个型号。尺度漂移=主体高占画面高的最大值相对首帧的增幅:
这给本提案加了一个比原动机更硬的理由:原动机是三个缺失能力(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 两个待验(不猜)
image_url不给prompt时,合法 dataURI 与非法裸串返回同一个「prompt 缺失」,说明 image 在 prompt 之后才校验。若只吃公网 URL,则调用前要上传对象存储(产品已有该能力)。定这条要一次真实提交billing_type_description是「可灵3.0Turbo 720P有声视频」,即使请求里传了generate_audio: false。我们生成完丢音轨,这笔是白付的;有无声档能省则省,未查到横评产物与逐条读数在本地归档(八条原视频 + 共同首帧 + 对照条),不入仓。
Refs #192
Refs #197
Refs #331