From fd7bfd4c8197d9005c1d11c72fbf8542cf17ef83 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Mon, 24 Aug 2026 01:27:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(providers):=20=E6=96=B0=E5=A2=9E=20FAL?= =?UTF-8?q?=20=E9=98=9F=E5=88=97=E5=8D=8F=E8=AE=AE=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 #332 第 6.2 节的接口写第二个面,只交付协议实现与脱网单测,不接 ALLOWED_VIDEO_MODELS、不换主力模型。 三处与 OpenAI 面不同,均按 2026-08-24 实测:鉴权头是 Key 而不是 Bearer; 建单路径带 queue/ 前缀且与 /v1 平级,故 base_url 要先退回网关根;首帧字段名 按端点族分 start_image_url 与 image_url 两种,表驱动不按字符串猜。 成败不在轮询那一步显形 —— 成功与失败的任务在 /status 都是 200 + COMPLETED, 所以本面的 build_fetch 返回真的 HttpCall,并给 JobProtocol 补上 parse_fetch。 取结果的 400 也不一律是客户端错误:veo3.1 与 vidu 未就绪时返回 400 + IN_PROGRESS,判成失败会把还在跑、可能已计费的任务判死。 轮询与取结果的地址由建单端点的前两段重建,而不是把 URL 带进接口:ledger 里 只持久化 job_id,一个离开 job_id 就重建不出地址的协议面日后无法从单号恢复。 首帧沿用 OpenAI 面的 JPEG dataURI:四个端点各喂一张纯品红图,产物首帧同色, 故不需要 bytes → 公网 URL 的上传器,sufy.py 里那条相反的注释一并更正。 39 条脱网单测全部以实测响应体为 fixture。8 个变异(鉴权头 / queue 前缀 / 首帧字段名 / 轮询前缀取三段 / base_url 不退根 / 400 当客户端错 / 500 不看 detail / build_fetch 返回 None)逐个确认有用例转红,还原后 sha256 一致。 --- .../providers/protocol/__init__.py | 4 + .../providers/protocol/fal_queue.py | 243 ++++++++++++ .../providers/protocol/openai_video.py | 6 +- .../providers/protocol/types.py | 6 +- .../src/windup_framework/providers/sufy.py | 10 +- backend/tests/test_fal_queue_protocol.py | 353 ++++++++++++++++++ 6 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py create mode 100644 backend/tests/test_fal_queue_protocol.py diff --git a/backend/packages/framework/src/windup_framework/providers/protocol/__init__.py b/backend/packages/framework/src/windup_framework/providers/protocol/__init__.py index 753e4202..75620187 100644 --- a/backend/packages/framework/src/windup_framework/providers/protocol/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/protocol/__init__.py @@ -1,10 +1,14 @@ +from .fal_queue import FAL_I2V_ENDPOINTS, FalQueueVideoProtocol, UnknownFalEndpointError from .openai_video import IMAGE_LIST_MODELS, OpenAIVideoProtocol from .types import HttpCall, JobProtocol, VideoRequest __all__ = [ + "FAL_I2V_ENDPOINTS", "IMAGE_LIST_MODELS", + "FalQueueVideoProtocol", "HttpCall", "JobProtocol", "OpenAIVideoProtocol", + "UnknownFalEndpointError", "VideoRequest", ] diff --git a/backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py b/backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py new file mode 100644 index 00000000..50945ff0 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py @@ -0,0 +1,243 @@ +"""FAL 队列面 ``/queue/*``。 + +与 OpenAI 面的形状差别只有一处实质性的:成败不在轮询那一步显形。成功与失败的任务在 +``/status`` 都是 200 + ``COMPLETED``,只有取结果那一步才分得开,所以本面的 +``build_fetch`` 返回真的 ``HttpCall`` 而不是 ``None``。 +""" +from __future__ import annotations + +from collections.abc import Mapping + +import httpx + +from windup_common.enums.model import ModelErrorType +from windup_framework.gateway.classify import edge_fingerprint +from windup_framework.gateway.types import AdapterResult + +from .openai_video import first_frame_datauri, http_error +from .types import HttpCall, VideoRequest + +#: 端点 → 首帧字段名。同为 kling,o1 叫 ``start_image_url``,别家叫 ``image_url``; +#: 猜不出来也不能按前缀推。塞错字段建单会被 400 拒,而少一个字段的那种拒法 +#: (``{msg}_url is required``)与"这个端点不存在"在响应里长得一样。 +FAL_I2V_ENDPOINTS: Mapping[str, str] = { + "fal-ai/kling-video/o1/image-to-video": "start_image_url", + "bytedance/seedance-2.0/image-to-video": "image_url", + "fal-ai/veo3.1/image-to-video": "image_url", + "fal-ai/vidu/q1/image-to-video": "image_url", +} + +#: 队列里还在跑。除这两个之外的状态一律当终态处理 —— 认不出的状态继续轮询,会把 +#: "协议变了"伪装成"生成太慢",转满预算才报超时。 +IN_FLIGHT = ("IN_QUEUE", "IN_PROGRESS") + + +class UnknownFalEndpointError(ValueError): + """端点不在 :data:`FAL_I2V_ENDPOINTS` 里。 + + 不做前缀匹配、不做兜底:猜中一条"存在但语义不同"的路径(把 image-to-video 猜成 + reference-to-video)会正常出片、正常计费。 + """ + + +def gateway_root(base_url: str) -> str: + """把 OpenAI 兼容面的 base_url 退回网关根。 + + ``/queue/...`` 与 ``/v1/...`` 平级,拿 ``.../v1`` 直接拼会得到 ``/v1/queue/...`` → 404。 + """ + root = base_url.rstrip("/") + return root[: -len("/v1")] if root.endswith("/v1") else root + + +def queue_prefix(endpoint: str) -> str: + """建单端点 → 轮询与取结果共用的前缀,取前两段。 + + 后面几段(``o1`` / ``image-to-video`` / ``pro``)只在建单时有意义,单据地址不带它们 + —— 六个 kling 型号共用同一个前缀。 + """ + return "/".join(endpoint.strip("/").split("/")[:2]) + + +class FalQueueVideoProtocol: + """一个实例对应一个端点:该面的型号由端点路径表达,请求体里不带 model 字段。""" + + def __init__(self, api_key: str, endpoint: str, *, base_url: str) -> None: + if endpoint not in FAL_I2V_ENDPOINTS: + raise UnknownFalEndpointError( + f"端点 {endpoint!r} 不在 FAL 图生视频端点表里," + f"已登记: {sorted(FAL_I2V_ENDPOINTS)}" + ) + self._key = api_key + self._endpoint = endpoint + self._root = gateway_root(base_url) + + @property + def _headers(self) -> dict[str, str]: + # 本面是 ``Key`` 而不是 ``Bearer``,写错时的 401 与"模型不存在"难以区分。 + return {"Authorization": f"Key {self._key}"} + + def _requests_url(self, job_id: str) -> str: + return f"{self._root}/queue/{queue_prefix(self._endpoint)}/requests/{job_id}" + + def build_submit(self, req: VideoRequest) -> HttpCall: + """首帧走 base64 dataURI。 + + ``req.seconds`` 目前落不到请求体上:十个端点的时长字段虽同叫 ``duration``,取值 + 形态却分 ``5`` / ``"5"`` / ``"5s"`` 三种且未逐个实测,猜错就是一次已计费的 400。 + """ + body: dict[str, object] = { + "prompt": req.prompt, + FAL_I2V_ENDPOINTS[self._endpoint]: first_frame_datauri( + req.first_frame, req.size + ), + } + # 路径是绝对地址而不是相对路径:客户端的 base_url 指着 OpenAI 面的 ``/v1``, + # 交给它拼会拼出 ``/v1/queue/...``。 + return HttpCall( + method="POST", + path=f"{self._root}/queue/{self._endpoint}", + headers=self._headers, + body=body, + ) + + def parse_submit(self, resp: httpx.Response) -> AdapterResult: + if not (200 <= resp.status_code < 300): + return http_error(resp) + try: + payload = resp.json() + except ValueError: + return AdapterResult( + ok=False, + error_type=ModelErrorType.INVALID_RESPONSE, + http_status=resp.status_code, + edge_fingerprint="响应不是 JSON", + ) + jid = payload.get("request_id") + if not jid: + return AdapterResult( + ok=False, + error_type=ModelErrorType.INVALID_RESPONSE, + http_status=resp.status_code, + edge_fingerprint="响应没有 request_id", + ) + return AdapterResult( + ok=True, + job_id=str(jid), + body=b"", + maybe_billed=True, + http_status=resp.status_code, + ) + + def build_poll(self, job_id: str) -> HttpCall: + return HttpCall( + method="GET", path=f"{self._requests_url(job_id)}/status", headers=self._headers + ) + + def parse_poll(self, resp: httpx.Response, job_id: str) -> AdapterResult: + """``ok`` 只表示轮询到此为止,不表示成功 —— 成败要由 :meth:`parse_fetch` 判。""" + if not (200 <= resp.status_code < 300): + return http_error(resp, job_id=job_id, phase="follow") + try: + st = resp.json() + except ValueError: + return AdapterResult( + ok=False, + error_type=ModelErrorType.INVALID_RESPONSE, + http_status=resp.status_code, + job_id=job_id, + maybe_billed=True, + edge_fingerprint="轮询响应不是 JSON", + ) + status = st.get("status") + if status == "COMPLETED": + return AdapterResult( + ok=True, job_id=job_id, maybe_billed=True, job_status=status + ) + if status in IN_FLIGHT: + return AdapterResult(ok=False, job_id=job_id, maybe_billed=True, job_status=status) + return AdapterResult( + ok=False, + error_type=ModelErrorType.UPSTREAM_FAILED, + job_id=job_id, + maybe_billed=True, + job_status=str(status) if status is not None else None, + edge_fingerprint=str(st.get("detail") or status or ""), + ) + + def build_fetch(self, job_id: str) -> HttpCall | None: + return HttpCall(method="GET", path=self._requests_url(job_id), headers=self._headers) + + def parse_fetch(self, resp: httpx.Response, job_id: str) -> AdapterResult: + """取结果那一步才分得开成败。 + + 400 在这里不一定是"请求错":未就绪时 veo3.1 与 vidu 实测返回 400 + ``IN_PROGRESS``, + 当成客户端错误会把还在跑的任务判死,而单据已建、可能已计费。 + """ + detail = _detail(resp) + if resp.status_code == 400 and (pending := _in_flight_status(resp)): + return AdapterResult( + ok=False, job_id=job_id, maybe_billed=True, job_status=pending + ) + if resp.status_code >= 500 and detail: + return AdapterResult( + ok=False, + error_type=ModelErrorType.UPSTREAM_FAILED, + http_status=resp.status_code, + job_id=job_id, + maybe_billed=True, + # 网关自己的失败分类(VENDOR_FAILED / RUNTIME_CREATE_PROVIDER_FAILED)比 + # 一个恒为 COMPLETED 的状态值值钱得多。 + job_status=str(detail.get("type") or "") or None, + edge_fingerprint=str(detail.get("msg") or "") or edge_fingerprint(resp), + ) + if not (200 <= resp.status_code < 300): + return http_error(resp, job_id=job_id, phase="follow") + url = _video_url(resp) + if not url: + return AdapterResult( + ok=False, + error_type=ModelErrorType.INVALID_RESPONSE, + http_status=resp.status_code, + job_id=job_id, + maybe_billed=True, + edge_fingerprint="取结果 2xx 但没有 video.url", + ) + return AdapterResult( + ok=True, + job_id=job_id, + maybe_billed=True, + job_status="COMPLETED", + result_url=url, + ) + + +def _payload(resp: httpx.Response) -> dict: + try: + payload = resp.json() + except ValueError: + return {} + return payload if isinstance(payload, dict) else {} + + +def _detail(resp: httpx.Response) -> dict: + detail = _payload(resp).get("detail") + return detail if isinstance(detail, dict) else {} + + +def _in_flight_status(resp: httpx.Response) -> str | None: + status = _payload(resp).get("status") + return status if status in IN_FLIGHT else None + + +def _video_url(resp: httpx.Response) -> str | None: + """两处都认:取结果端点给的是顶层 ``video``,``/status`` 内联的那份裹在 ``result`` 里。 + + 只认一处而对面给的是另一处,丢掉的是一段已经生成、已经付过钱的视频。 + """ + payload = _payload(resp) + for holder in (payload, payload.get("result")): + if isinstance(holder, dict): + video = holder.get("video") + if isinstance(video, dict) and video.get("url"): + return str(video["url"]) + return None diff --git a/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py b/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py index bfa0ef8d..a0ff9215 100644 --- a/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py +++ b/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py @@ -72,7 +72,7 @@ def fit_first_frame( def first_frame_datauri(frame: bytes, size: str) -> str: - """首帧 → base64 dataURI(本面专用;FAL 队列面不吃 dataURI)。""" + """首帧 → base64 dataURI。FAL 队列面同样吃这个形状,故两面共用。""" return "data:image/jpeg;base64," + base64.b64encode(fit_first_frame(frame, size)).decode() @@ -197,3 +197,7 @@ def parse_poll(self, resp: httpx.Response, job_id: str) -> AdapterResult: def build_fetch(self, job_id: str) -> HttpCall | None: return None + + def parse_fetch(self, resp: httpx.Response, job_id: str) -> AdapterResult: + """本面的取结果地址就是轮询地址,所以两步解析是同一个。""" + return self.parse_poll(resp, job_id) diff --git a/backend/packages/framework/src/windup_framework/providers/protocol/types.py b/backend/packages/framework/src/windup_framework/providers/protocol/types.py index 93292afd..c5850361 100644 --- a/backend/packages/framework/src/windup_framework/providers/protocol/types.py +++ b/backend/packages/framework/src/windup_framework/providers/protocol/types.py @@ -39,7 +39,9 @@ class VideoRequest: class JobProtocol(Protocol): """建单 → 轮询 → 取结果。各协议面的差别只在路径、鉴权与字段名,形状同构。 - ``build_fetch`` 返回 ``None`` 表示该面的产物地址已在轮询响应里,无需再取一次。 + ``build_fetch`` 返回 ``None`` 表示该面的产物地址已在轮询响应里,无需再取一次; + 返回 ``HttpCall`` 的面把成败留到 ``parse_fetch`` 才揭晓,``parse_poll`` 的 ``ok`` + 此时只表示轮询到此为止。 """ def build_submit(self, req: VideoRequest) -> HttpCall: ... @@ -51,3 +53,5 @@ def build_poll(self, job_id: str) -> HttpCall: ... def parse_poll(self, resp: httpx.Response, job_id: str) -> AdapterResult: ... def build_fetch(self, job_id: str) -> HttpCall | None: ... + + def parse_fetch(self, resp: httpx.Response, job_id: str) -> AdapterResult: ... diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index 1af24d09..b0b4cb70 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -13,8 +13,8 @@ 图像走 OpenAI 兼容的 ``/chat/completions``(:class:`SufyImageProvider`),参考图以 data URI 塞进 ``content`` 数组 —— 与视频的提交-轮询-下载三段式完全不同的调用形状。 -**网关上还有另一套 FAL 队列面**(veo / seedance / vidu 只在那一面)。曾实现过,但因为 -从未被真实调用过而移除,见本文件中段那条注释里记下的两个实测事实。 +**网关上还有另一套 FAL 队列面**(veo / seedance / vidu 只在那一面),协议实现在 +:mod:`.protocol.fal_queue`;本 provider 尚未接它。 型号与 key / base_url 均由 ``AIProviderSettings`` 注入,provider 内不读 env;哪个模型吃 什么请求字段属该模型的 API 事实,写在代码里而不是配置里(填错只会在生成阶段才 failed, @@ -361,9 +361,9 @@ def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: # ── FAL 队列面 ────────────────────────────────────────────────────────────── # 2026-08-07 拉网关 OpenAPI spec 核对得到:平台的 22 个图生视频端点全在 /queue/ 下, -# 首帧字段一律是 URL 形态(image_url / start_image_url),同日实测送 dataURI 无一能用。 -# (spec 里 seedance / vidu-q3 / kling-v3-turbo 三家的字段说明写着"URL 或 base64", -# 与实测冲突,未复验。本实现一律只发公网 URL —— 那是 22 个端点的共同解。) +# 首帧字段一律是 URL 形态(image_url / start_image_url)。字段名虽叫 *_url,值可以是 +# base64 dataURI:2026-08-24 对下列四个端点各喂一张纯品红图,产物首帧同色,故两面共用 +# 同一套首帧编码,不需要 bytes → 公网 URL 的上传器。 # # 每家有三样东西不一样,而且**没有一条能靠拼字符串猜出来**,所以下面是一张硬表: # 1. 提交路径:型号段各不相同(o3 / v3 / v3/turbo / v2.6 / v2.5-turbo / o1), diff --git a/backend/tests/test_fal_queue_protocol.py b/backend/tests/test_fal_queue_protocol.py new file mode 100644 index 00000000..c11bfd3c --- /dev/null +++ b/backend/tests/test_fal_queue_protocol.py @@ -0,0 +1,353 @@ +"""FAL 队列面的脱网单测。 + +响应体全部取自 2026-08-24 对网关的实测,不是构造出来的样例 —— 这一面有三处只在真响应里 +才看得见:``/status`` 的 ``COMPLETED`` 不区分成败、取结果的 400 可能是"还没好"、 +以及首帧字段名按端点族分两种。 +""" +from __future__ import annotations + +import base64 +import io +from dataclasses import fields + +import httpx +import pytest + +from windup_common.enums.model import ModelErrorType +from windup_framework.gateway.types import AdapterResult +from windup_framework.providers.protocol import ( + FAL_I2V_ENDPOINTS, + FalQueueVideoProtocol, + OpenAIVideoProtocol, + UnknownFalEndpointError, + VideoRequest, +) +from windup_framework.providers.protocol.fal_queue import gateway_root, queue_prefix + +KLING = "fal-ai/kling-video/o1/image-to-video" +SEEDANCE = "bytedance/seedance-2.0/image-to-video" +VEO = "fal-ai/veo3.1/image-to-video" +VIDU = "fal-ai/vidu/q1/image-to-video" + +#: 配置里的 base_url 指向 OpenAI 面,``/queue`` 与 ``/v1`` 平级。 +BASE_URL = "https://api.qnaigc.com/v1" +ROOT = "https://api.qnaigc.com" + +REQUEST_ID = "qvideo-1382244847-1787504690896902712" + +#: 建单实测响应(kling)。``status_url`` 是网关自己给出的轮询地址,下面用它校准重建规则。 +SUBMIT_200 = { + "status": "IN_QUEUE", + "request_id": REQUEST_ID, + "response_url": f"{ROOT}/queue/fal-ai/kling-video/requests/{REQUEST_ID}", + "status_url": f"{ROOT}/queue/fal-ai/kling-video/requests/{REQUEST_ID}/status", +} + +FETCH_200 = {"video": {"url": "https://cdn.invalid/x.mp4", "duration": 5, + "content_type": "video/mp4"}} +FETCH_500_VENDOR = {"detail": {"loc": ["body"], "msg": "Image pixel is invalid", + "type": "VENDOR_FAILED", "url": ""}} +FETCH_500_CREATE = {"detail": { + "loc": ["body"], + "msg": "provider task id not found: ... provider_error=File is not in a valid base64 format", + "type": "RUNTIME_CREATE_PROVIDER_FAILED", + "url": "", +}} +FETCH_400_PENDING = {"status": "IN_PROGRESS", "request_id": REQUEST_ID} + + +def _png(size: tuple[int, int] = (64, 64)) -> bytes: + from PIL import Image + + buf = io.BytesIO() + Image.new("RGBA", size, (200, 30, 30, 255)).save(buf, "PNG") + return buf.getvalue() + + +def _req(model: str = "kling-video-o1") -> VideoRequest: + return VideoRequest( + model=model, prompt="向右走", seconds=5, size="1280x720", mode="std", + first_frame=_png(), + ) + + +def _fal(endpoint: str = KLING, key: str = "k-123") -> FalQueueVideoProtocol: + return FalQueueVideoProtocol(key, endpoint, base_url=BASE_URL) + + +def _populated(result: AdapterResult) -> set[str]: + """取值不等于默认值的字段名 —— #332 第 10 节的"两面产出同构"就是比这个集合。""" + return {f.name for f in fields(result) if getattr(result, f.name) != f.default} + + +# ── 建单 ──────────────────────────────────────────────────────────────────── + + +def test_auth_header_is_key_not_bearer(): + """本面是 ``Key``;与 ``Bearer`` 互换后的 401 与"模型不存在"难以区分。""" + fal = _fal().build_submit(_req()) + openai = OpenAIVideoProtocol("k-123").build_submit(_req()) + + assert fal.headers["Authorization"] == "Key k-123" + assert openai.headers["Authorization"] == "Bearer k-123" + + +def test_submit_path_keeps_the_queue_prefix_and_leaves_v1_behind(): + """少了 ``queue/`` 全部 404;而拿 ``.../v1`` 直接拼会拼出 ``/v1/queue/...``。""" + call = _fal().build_submit(_req()) + + assert call.method == "POST" + assert call.path == f"{ROOT}/queue/{KLING}" + assert "/v1/" not in call.path + + +def test_kling_o1_wants_start_image_url(): + body = _fal(KLING).build_submit(_req()).body + + assert "start_image_url" in body and "image_url" not in body + + +@pytest.mark.parametrize("endpoint", [SEEDANCE, VEO, VIDU]) +def test_the_other_three_want_image_url(endpoint): + body = _fal(endpoint).build_submit(_req()).body + + assert "image_url" in body and "start_image_url" not in body + + +def test_first_frame_is_a_jpeg_datauri(): + """四个端点都接受 dataURI(2026-08-24 实测),所以不需要 bytes → 公网 URL 的上传器。""" + body = _fal().build_submit(_req()).body + + ref = body["start_image_url"] + assert ref.startswith("data:image/jpeg;base64,") + assert base64.b64decode(ref.split(",", 1)[1])[:2] == b"\xff\xd8" + + +def test_body_carries_no_model_field(): + """型号由端点路径表达,不是请求体里的一个字段。""" + assert "model" not in _fal().build_submit(_req()).body + + +def test_seconds_does_not_reach_the_body_yet(): + """时长字段虽都叫 ``duration``,取值形态分 5 / "5" / "5s" 三种且未逐个实测。 + + 这一条钉住的是一个已知缺口:接进编排之前必须补测,否则 ``seconds`` 静默失效。 + """ + assert "duration" not in _fal().build_submit(_req(model="veo3.1")).body + + +def test_unregistered_endpoint_is_refused_at_construction(): + """猜中一条"存在但语义不同"的路径会正常出片、正常计费,所以不做前缀匹配。""" + with pytest.raises(UnknownFalEndpointError, match="不在 FAL 图生视频端点表里"): + _fal("fal-ai/kling-video/o1/reference-to-video") + + +def test_submit_response_without_request_id_is_invalid(): + """本面的单号叫 ``request_id``,不是 OpenAI 面那个 ``id``。""" + fal = _fal() + + assert fal.parse_submit(httpx.Response(200, json=SUBMIT_200)).job_id == REQUEST_ID + assert fal.parse_submit( + httpx.Response(200, json={"id": "job-9"}) + ).error_type is ModelErrorType.INVALID_RESPONSE + + +@pytest.mark.parametrize( + "detail", ["invalid or unsafe url", "start_image_url is required"] +) +def test_rejected_submit_opens_no_job(detail): + """建单期的参数校验因端点而异,被拒时还没有单据,不能带着 job_id 回去。""" + parsed = _fal().parse_submit(httpx.Response(400, json={"detail": detail})) + + assert not parsed.ok and parsed.http_status == 400 + assert parsed.job_id is None and not parsed.maybe_billed + + +def test_poll_404_keeps_the_job_id(): + """轮询失败要带着单号回去,否则重试会新开一单、二次计费。""" + parsed = _fal().parse_poll(httpx.Response(404, text="not found"), REQUEST_ID) + + assert parsed.error_type is ModelErrorType.JOB_NOT_FOUND + assert parsed.job_id == REQUEST_ID and parsed.maybe_billed + + +# ── 轮询 ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("endpoint", "prefix"), + [ + (KLING, "/queue/fal-ai/kling-video/requests/"), + (SEEDANCE, "/queue/bytedance/seedance-2.0/requests/"), + (VEO, "/queue/fal-ai/veo3.1/requests/"), + (VIDU, "/queue/fal-ai/vidu/requests/"), + ], +) +def test_poll_prefix_is_the_first_two_segments(endpoint, prefix): + """单据地址丢掉建单路径的第三段起 —— 这些前缀是 2026-08-24 逐个实测到的。""" + call = _fal(endpoint).build_poll(REQUEST_ID) + + assert call.path == f"{ROOT}{prefix}{REQUEST_ID}/status" + assert call.method == "GET" + + +def test_rebuilt_poll_url_matches_what_the_gateway_handed_back(): + """重建规则的校准点:与建单响应里的 ``status_url`` / ``response_url`` 逐字节比对。""" + fal = _fal(KLING) + + assert fal.build_poll(REQUEST_ID).path == SUBMIT_200["status_url"] + assert fal.build_fetch(REQUEST_ID).path == SUBMIT_200["response_url"] + + +@pytest.mark.parametrize("status", ["IN_QUEUE", "IN_PROGRESS"]) +def test_in_flight_poll_has_no_error_type(status): + """adapter 靠"没有 error_type"判定该继续轮询。""" + parsed = _fal().parse_poll(httpx.Response(200, json={"status": status}), REQUEST_ID) + + assert parsed.error_type is None and not parsed.ok + assert parsed.job_status == status + + +def test_completed_poll_stops_polling_without_claiming_success(): + """``COMPLETED`` 不是成功信号:成败要等取结果。所以这一步不给 ``result_url``。""" + parsed = _fal().parse_poll(httpx.Response(200, json={"status": "COMPLETED"}), REQUEST_ID) + + assert parsed.ok and parsed.result_url is None + assert _fal().build_fetch(REQUEST_ID) is not None, "本面必须真去取一次结果" + + +def test_unrecognised_poll_status_is_a_failure_not_a_wait(): + """继续轮询会把"协议变了"伪装成"生成太慢",转满预算才报超时。""" + parsed = _fal().parse_poll(httpx.Response(200, json={"status": "FAILED"}), REQUEST_ID) + + assert parsed.error_type is ModelErrorType.UPSTREAM_FAILED + assert parsed.job_id == REQUEST_ID and parsed.maybe_billed + + +# ── 取结果 ────────────────────────────────────────────────────────────────── + + +def test_fetch_200_hands_back_the_video_url(): + parsed = _fal().parse_fetch(httpx.Response(200, json=FETCH_200), REQUEST_ID) + + assert parsed.ok and parsed.result_url == "https://cdn.invalid/x.mp4" + assert parsed.job_status == "COMPLETED" + + +def test_fetch_also_reads_a_url_nested_under_result(): + """只认一处而对面给的是另一处,丢掉的是一段已生成、已付费的视频。""" + parsed = _fal().parse_fetch( + httpx.Response(200, json={"result": FETCH_200}), REQUEST_ID + ) + + assert parsed.ok and parsed.result_url == "https://cdn.invalid/x.mp4" + + +def test_fetch_2xx_without_a_url_is_invalid_not_ok(): + parsed = _fal().parse_fetch(httpx.Response(200, json={"video": {}}), REQUEST_ID) + + assert not parsed.ok and parsed.error_type is ModelErrorType.INVALID_RESPONSE + + +@pytest.mark.parametrize( + ("payload", "kind", "msg"), + [ + (FETCH_500_VENDOR, "VENDOR_FAILED", "Image pixel is invalid"), + (FETCH_500_CREATE, "RUNTIME_CREATE_PROVIDER_FAILED", "not in a valid base64 format"), + ], +) +def test_completed_but_fetch_500_is_a_failure(payload, kind, msg): + """``/status`` 说 COMPLETED、取结果却 500 —— 成败只有这一步分得开。""" + fal = _fal() + assert fal.parse_poll(httpx.Response(200, json={"status": "COMPLETED"}), REQUEST_ID).ok + + parsed = fal.parse_fetch(httpx.Response(500, json=payload), REQUEST_ID) + + assert parsed.error_type is ModelErrorType.UPSTREAM_FAILED + assert parsed.maybe_billed and parsed.job_id == REQUEST_ID + assert parsed.job_status == kind, "网关自己的失败分类比恒为 COMPLETED 的状态值值钱" + assert msg in parsed.edge_fingerprint + + +def test_fetch_400_in_progress_is_not_ready_rather_than_a_client_error(): + """veo3.1 与 vidu 未就绪时实测返回 400 —— 按客户端错误处理会把在跑的任务判死。""" + parsed = _fal(VEO).parse_fetch(httpx.Response(400, json=FETCH_400_PENDING), REQUEST_ID) + + assert parsed.error_type is None and not parsed.ok + assert parsed.job_status == "IN_PROGRESS" and parsed.job_id == REQUEST_ID + + +def test_a_real_400_is_still_a_400(): + """未就绪那条不能宽到把真的参数错也放过。""" + parsed = _fal().parse_fetch( + httpx.Response(400, json={"detail": "invalid or unsafe url"}), REQUEST_ID + ) + + assert parsed.error_type is not None and parsed.http_status == 400 + + +def test_fetch_500_without_detail_falls_back_to_the_http_classifier(): + parsed = _fal().parse_fetch(httpx.Response(500, text="bad gateway"), REQUEST_ID) + + assert parsed.error_type is ModelErrorType.MAYBE_BILLED and parsed.maybe_billed + + +# ── 两面同构(#332 第 10 节)───────────────────────────────────────────────── + + +def test_both_faces_report_a_submitted_job_the_same_way(): + fal = _fal().parse_submit(httpx.Response(200, json=SUBMIT_200)) + openai = OpenAIVideoProtocol("k").parse_submit(httpx.Response(200, json={"id": "job-9"})) + + assert _populated(fal) == _populated(openai) + assert fal.ok and openai.ok + + +def test_both_faces_report_a_job_still_running_the_same_way(): + fal = _fal().parse_poll(httpx.Response(200, json={"status": "IN_PROGRESS"}), "j") + pending_at_fetch = _fal(VEO).parse_fetch( + httpx.Response(400, json=FETCH_400_PENDING), "j" + ) + openai = OpenAIVideoProtocol("k").parse_poll( + httpx.Response(200, json={"status": "processing"}), "j" + ) + + assert _populated(fal) == _populated(openai) == _populated(pending_at_fetch) + + +def test_both_faces_report_a_finished_video_the_same_way(): + """一面的产物地址来自轮询、另一面来自取结果,交回给 adapter 的形状必须一致。""" + fal = _fal().parse_fetch(httpx.Response(200, json=FETCH_200), "j") + openai = OpenAIVideoProtocol("k").parse_poll( + httpx.Response( + 200, + json={"status": "completed", + "task_result": {"videos": [{"url": "https://cdn.invalid/x.mp4"}]}}, + ), + "j", + ) + + assert _populated(fal) == _populated(openai) + assert fal.result_url == openai.result_url + + +# ── 两个纯函数 ────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("base", "expected"), + [ + ("https://api.qnaigc.com/v1", ROOT), + ("https://api.qnaigc.com/v1/", ROOT), + ("https://api.qnaigc.com", ROOT), + ("https://api.qnaigc.com/gw/v1", "https://api.qnaigc.com/gw"), + ], +) +def test_gateway_root_strips_the_openai_face(base, expected): + assert gateway_root(base) == expected + + +def test_queue_prefix_covers_every_registered_endpoint(): + """表里每一条都必须能被规则算出前两段,新登记一条时这里会先红。""" + for endpoint in FAL_I2V_ENDPOINTS: + assert queue_prefix(endpoint).count("/") == 1 From 6e8b18674d69132ae2b5bcc3990bf9424a2a0b26 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Mon, 24 Aug 2026 01:31:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(providers):=20=E9=A6=96=E5=B8=A7?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E7=9A=84=E5=AE=9E=E6=B5=8B=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E5=86=99=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原注释说四个端点都用纯品红图验过,实际只验了三个、且两个用的是中灰图; seedance-2.0 因输入尺寸下限 14px 拒了那次探针,没验到。 --- .../packages/framework/src/windup_framework/providers/sufy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index b0b4cb70..1d6c7b4f 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -362,8 +362,10 @@ def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: # ── FAL 队列面 ────────────────────────────────────────────────────────────── # 2026-08-07 拉网关 OpenAPI spec 核对得到:平台的 22 个图生视频端点全在 /queue/ 下, # 首帧字段一律是 URL 形态(image_url / start_image_url)。字段名虽叫 *_url,值可以是 -# base64 dataURI:2026-08-24 对下列四个端点各喂一张纯品红图,产物首帧同色,故两面共用 +# base64 dataURI —— 2026-08-24 实测三个端点:kling-video/o1 喂纯品红图,产物首帧 +# 平均 RGB (255,1,201);vidu/q1 与 veo3.1 喂纯中灰图,产物首帧同为纯中灰。故两面共用 # 同一套首帧编码,不需要 bytes → 公网 URL 的上传器。 +# (seedance-2.0 未验:它的输入尺寸下限是 14px,那次探针的图只有 8px 被上游拒。) # # 每家有三样东西不一样,而且**没有一条能靠拼字符串猜出来**,所以下面是一张硬表: # 1. 提交路径:型号段各不相同(o3 / v3 / v3/turbo / v2.6 / v2.5-turbo / o1), From 78f502406978a46ce1d87739e3a28cb98c2e837b Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Mon, 24 Aug 2026 11:55:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(providers):=202xx=20=E7=9A=84=E9=9D=9E?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=20JSON=20=E6=94=B6=E6=88=90=20INVALID=5FRESP?= =?UTF-8?q?ONSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只挡解码失败不够:上游在 2xx 下返回合法的数组 / 字符串 / null 时,直接 .get() 会抛 AttributeError,请求以未处理异常结束,而单据可能已经建了。两个协议面共用一个校验。 --- .../providers/protocol/fal_queue.py | 22 ++++++--------- .../providers/protocol/openai_video.py | 28 +++++++++++++------ backend/tests/test_fal_queue_protocol.py | 13 +++++++++ backend/tests/test_video_protocol.py | 10 +++++++ 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py b/backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py index 50945ff0..32fb2719 100644 --- a/backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py +++ b/backend/packages/framework/src/windup_framework/providers/protocol/fal_queue.py @@ -14,7 +14,7 @@ from windup_framework.gateway.classify import edge_fingerprint from windup_framework.gateway.types import AdapterResult -from .openai_video import first_frame_datauri, http_error +from .openai_video import first_frame_datauri, http_error, json_object from .types import HttpCall, VideoRequest #: 端点 → 首帧字段名。同为 kling,o1 叫 ``start_image_url``,别家叫 ``image_url``; @@ -103,14 +103,13 @@ def build_submit(self, req: VideoRequest) -> HttpCall: def parse_submit(self, resp: httpx.Response) -> AdapterResult: if not (200 <= resp.status_code < 300): return http_error(resp) - try: - payload = resp.json() - except ValueError: + payload = json_object(resp) + if payload is None: return AdapterResult( ok=False, error_type=ModelErrorType.INVALID_RESPONSE, http_status=resp.status_code, - edge_fingerprint="响应不是 JSON", + edge_fingerprint="响应不是 JSON 对象", ) jid = payload.get("request_id") if not jid: @@ -137,16 +136,15 @@ def parse_poll(self, resp: httpx.Response, job_id: str) -> AdapterResult: """``ok`` 只表示轮询到此为止,不表示成功 —— 成败要由 :meth:`parse_fetch` 判。""" if not (200 <= resp.status_code < 300): return http_error(resp, job_id=job_id, phase="follow") - try: - st = resp.json() - except ValueError: + st = json_object(resp) + if st is None: return AdapterResult( ok=False, error_type=ModelErrorType.INVALID_RESPONSE, http_status=resp.status_code, job_id=job_id, maybe_billed=True, - edge_fingerprint="轮询响应不是 JSON", + edge_fingerprint="轮询响应不是 JSON 对象", ) status = st.get("status") if status == "COMPLETED": @@ -212,11 +210,7 @@ def parse_fetch(self, resp: httpx.Response, job_id: str) -> AdapterResult: def _payload(resp: httpx.Response) -> dict: - try: - payload = resp.json() - except ValueError: - return {} - return payload if isinstance(payload, dict) else {} + return json_object(resp) or {} def _detail(resp: httpx.Response) -> dict: diff --git a/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py b/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py index a0ff9215..f3d978a8 100644 --- a/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py +++ b/backend/packages/framework/src/windup_framework/providers/protocol/openai_video.py @@ -103,6 +103,20 @@ def http_error( ) +def json_object(resp: httpx.Response) -> dict | None: + """2xx 响应里的 JSON 对象;不是对象就返回 ``None``。 + + 只挡解码失败不够:上游在 2xx 下返回合法的数组 / 字符串 / ``null`` 时, + 直接 ``.get()`` 会抛 ``AttributeError``,请求以未处理异常结束, + 而不是被收成 ``INVALID_RESPONSE`` 交给 Gateway 判。 + """ + try: + payload = resp.json() + except ValueError: + return None + return payload if isinstance(payload, dict) else None + + class OpenAIVideoProtocol: """鉴权头由本层产出而不由厂商层统一注入 —— 写错时的响应与"模型不存在"难以区分。""" @@ -131,14 +145,13 @@ def build_submit(self, req: VideoRequest) -> HttpCall: def parse_submit(self, resp: httpx.Response) -> AdapterResult: if not (200 <= resp.status_code < 300): return http_error(resp) - try: - payload = resp.json() - except ValueError: + payload = json_object(resp) + if payload is None: return AdapterResult( ok=False, error_type=ModelErrorType.INVALID_RESPONSE, http_status=resp.status_code, - edge_fingerprint="响应不是 JSON", + edge_fingerprint="响应不是 JSON 对象", ) jid = payload.get("id") if not jid: @@ -163,16 +176,15 @@ def parse_poll(self, resp: httpx.Response, job_id: str) -> AdapterResult: """未完成时 ``error_type`` 为 ``None`` 且 ``ok`` 为假 —— adapter 据此继续轮询。""" if not (200 <= resp.status_code < 300): return http_error(resp, job_id=job_id, phase="follow") - try: - st = resp.json() - except ValueError: + st = json_object(resp) + if st is None: return AdapterResult( ok=False, error_type=ModelErrorType.INVALID_RESPONSE, http_status=resp.status_code, job_id=job_id, maybe_billed=True, - edge_fingerprint="轮询响应不是 JSON", + edge_fingerprint="轮询响应不是 JSON 对象", ) status = st.get("status") if status == "completed": diff --git a/backend/tests/test_fal_queue_protocol.py b/backend/tests/test_fal_queue_protocol.py index c11bfd3c..68adf4f7 100644 --- a/backend/tests/test_fal_queue_protocol.py +++ b/backend/tests/test_fal_queue_protocol.py @@ -351,3 +351,16 @@ def test_queue_prefix_covers_every_registered_endpoint(): """表里每一条都必须能被规则算出前两段,新登记一条时这里会先红。""" for endpoint in FAL_I2V_ENDPOINTS: assert queue_prefix(endpoint).count("/") == 1 + + +@pytest.mark.parametrize("body", ["[1, 2]", '"just a string"', "null", "17"]) +def test_a_2xx_that_is_not_a_json_object_is_invalid_not_a_crash(body): + """2xx 下返回合法但非对象的 JSON,要收成 INVALID_RESPONSE 而不是抛 AttributeError。 + + 抛出去的话请求以未处理异常结束,Gateway 拿不到可判的结果,而单据可能已经建了。 + """ + p = _fal() + resp = httpx.Response(200, content=body, headers={"content-type": "application/json"}) + + assert p.parse_submit(resp).error_type is ModelErrorType.INVALID_RESPONSE + assert p.parse_poll(resp, "job-9").error_type is ModelErrorType.INVALID_RESPONSE diff --git a/backend/tests/test_video_protocol.py b/backend/tests/test_video_protocol.py index 49095938..9edbff72 100644 --- a/backend/tests/test_video_protocol.py +++ b/backend/tests/test_video_protocol.py @@ -130,3 +130,13 @@ def test_poll_path_carries_the_job_id(): assert (call.method, call.path) == ("GET", "/videos/job-9") assert call.headers["Authorization"] == "Bearer k" + + +@pytest.mark.parametrize("body", ["[1, 2]", '"just a string"', "null"]) +def test_a_2xx_that_is_not_a_json_object_is_invalid_not_a_crash(body): + """同队列面:2xx 下的非对象 JSON 要收成 INVALID_RESPONSE,不能抛 AttributeError。""" + p = OpenAIVideoProtocol("k") + resp = httpx.Response(200, content=body, headers={"content-type": "application/json"}) + + assert p.parse_submit(resp).error_type is ModelErrorType.INVALID_RESPONSE + assert p.parse_poll(resp, "job-9").error_type is ModelErrorType.INVALID_RESPONSE