Skip to content

Proposal: 接入 Fal 队列协议面,并把协议表达为显式接缝 #332

Description

@johnnyzhang-eng

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-controlreference-to-video:同一接缝落地后各自单独提案。
  • 厂商维度(base_url / 鉴权 / 型号目录)的拆分。
  • chat 能力、providers/image.pyproviders/video.py 的去留。
  • 原厂 Bypass 面(/bypass/anthropic/v1/messages 等)。
  • 重试、Fallback、熔断、trace:已由 feat(gateway): 图/视频调用经进程内 Gateway 做重试、Fallback 与观测 #331gateway/ 承担,本提案不改。

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.pyFAL_RUN_HOSTFAL_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

Metadata

Metadata

Labels

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions