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
motion-control 与 reference-to-video:同一接缝落地后各自单独提案。
厂商维度(base_url / 鉴权 / 型号目录)的拆分。
chat 能力、providers/image.py 与 providers/video.py 的去留。
原厂 Bypass 面(/bypass/anthropic/v1/messages 等)。
重试、Fallback、熔断、trace:已由 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,调用方只有 bytes
由 adapter 完成 bytes → URL,不把差异漏给 ai_engine
首尾帧只给了一张
退回普通 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}" —— AppId.from_endpoint_id 把 endpoint 拆成 owner / alias / path 之后,重拼时只用前两段,path 被丢掉。对 fal 官方是对的(它的状态地址本就是这个形状),对本网关不成立:fal-ai/kling-image/o1 会被拼成 .../fal-ai/kling-image/requests/{id},o1 连同 /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 给出结论。
12. Summary of Changes
位置
改动
providers/protocol/(新增)
JobProtocol 接口 + OpenAI 面与 Fal 面两个实现
gateway/registry.py
FAMILIES 取值由“请求形状”改为“协议面 + 能力”,键不变
providers/sufy.py
请求构造与响应解析迁出,行为不变
测试
协议层脱网单测;OpenAI 面既有测试不变
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 已有判据,不变)ai_engine7. 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}"——AppId.from_endpoint_id把 endpoint 拆成owner / alias / path之后,重拼时只用前两段,path被丢掉。对 fal 官方是对的(它的状态地址本就是这个形状),对本网关不成立:fal-ai/kling-image/o1会被拼成.../fal-ai/kling-image/requests/{id},o1连同/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 给出结论。12. Summary of Changes
providers/protocol/(新增)JobProtocol接口 + OpenAI 面与 Fal 面两个实现gateway/registry.pyFAMILIES取值由“请求形状”改为“协议面 + 能力”,键不变providers/sufy.pyRefs #192
Refs #197
Refs #331