Ver Fonte

添加flux2.0

kevin.yang há 4 dias atrás
pai
commit
74b40d3b62

+ 15 - 0
data/groups.json

@@ -31,6 +31,21 @@
         "ji_meng_query_task"
         "ji_meng_query_task"
       ],
       ],
       "usage_example": "1. 调用 ji_meng_add_task 传入 prompt,得到 task_id\n2. 轮询 ji_meng_query_task 传入 task_id,直到完成"
       "usage_example": "1. 调用 ji_meng_add_task 传入 prompt,得到 task_id\n2. 轮询 ji_meng_query_task 传入 task_id,直到完成"
+    },
+    {
+      "group_id": "flux_bfl_lifecycle",
+      "name": "FLUX(BFL 提交与轮询)",
+      "description": "先提交生图拿到 id 与 polling_url,再轮询直至 Ready",
+      "category": "remote",
+      "tool_ids": [
+        "flux_submit",
+        "flux_query"
+      ],
+      "usage_order": [
+        "flux_submit",
+        "flux_query"
+      ],
+      "usage_example": "1. flux_submit 传入 model(如 flux-2-pro-preview)、prompt,得到 id 与 polling_url\n2. 反复调用 flux_query 传入 polling_url、request_id(即 id),直到 status 为 Ready,从 result.sample 取图(签名 URL 约 10 分钟有效)"
     }
     }
   ],
   ],
   "version": "1.0"
   "version": "1.0"

+ 98 - 0
data/registry.json

@@ -555,6 +555,104 @@
       "status": "active",
       "status": "active",
       "backend_runtime": "local",
       "backend_runtime": "local",
       "group_ids": []
       "group_ids": []
+    },
+    {
+      "tool_id": "flux_submit",
+      "name": "FLUX-提交生图任务",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "向 BFL 提交异步生图任务(POST /v1/{model})。需 BFL_API_KEY;model 为端点名如 flux-2-pro-preview。返回 id、polling_url 供 flux_query 轮询。文档 https://docs.bfl.ai/quick_start/generating_images",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "model": {
+            "type": "string",
+            "description": "端点路径段,如 flux-2-pro-preview、flux-2-max、flux-dev、flux-kontext-pro 等"
+          },
+          "prompt": {
+            "type": "string",
+            "description": "提示词"
+          },
+          "width": {
+            "type": "integer",
+            "description": "输出宽度(像素),可选"
+          },
+          "height": {
+            "type": "integer",
+            "description": "输出高度(像素),可选"
+          },
+          "parameters": {
+            "type": "object",
+            "description": "合并进 BFL 请求体的额外字段(模型专有参数)"
+          }
+        },
+        "required": [
+          "model",
+          "prompt"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "请求 ID,flux_query 的 request_id"
+          },
+          "polling_url": {
+            "type": "string",
+            "description": "轮询地址,须原样传给 flux_query(全球端点必须用返回的 URL)"
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": [
+        "flux_bfl_lifecycle"
+      ]
+    },
+    {
+      "tool_id": "flux_query",
+      "name": "FLUX-查询任务结果",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "按 flux_submit 返回的 polling_url 与 id 轮询状态;Ready 时 result.sample 为签名图 URL(约 10 分钟有效)。",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "polling_url": {
+            "type": "string",
+            "description": "提交响应中的 polling_url"
+          },
+          "request_id": {
+            "type": "string",
+            "description": "提交响应中的 id"
+          }
+        },
+        "required": [
+          "polling_url",
+          "request_id"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "status": {
+            "type": "string",
+            "description": "如 Ready、Pending、Error、Failed 等"
+          },
+          "result": {
+            "type": "object",
+            "description": "成功时常含 sample(图片签名 URL)"
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": [
+        "flux_bfl_lifecycle"
+      ]
     }
     }
   ],
   ],
   "version": "2.0"
   "version": "2.0"

+ 28 - 0
data/sources.json

@@ -111,6 +111,34 @@
         "http_method": "POST",
         "http_method": "POST",
         "internal_port": 0
         "internal_port": 0
       }
       }
+    ],
+    "flux_submit": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/flux",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/submit",
+        "http_method": "POST",
+        "internal_port": 0
+      }
+    ],
+    "flux_query": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/flux",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/query",
+        "http_method": "POST",
+        "internal_port": 0
+      }
     ]
     ]
   }
   }
 }
 }

+ 1 - 0
pyproject.toml

@@ -43,4 +43,5 @@ members = [
     "tools/local/runcomfy_stop_env",
     "tools/local/runcomfy_stop_env",
     "tools/local/ji_meng",
     "tools/local/ji_meng",
     "tools/local/nano_banana",
     "tools/local/nano_banana",
+    "tools/local/flux",
 ]
 ]

+ 177 - 0
tests/test_flux.py

@@ -0,0 +1,177 @@
+"""测试 BFL FLUX 异步生图 — 通过 Router POST /run_tool
+
+官方流程:先 POST 提交任务拿到 id + polling_url,再轮询 polling_url 直至 Ready。
+文档: https://docs.bfl.ai/quick_start/generating_images
+
+用法:
+    1. 配置 tools/local/flux/.env:BFL_API_KEY
+    2. uv run python -m tool_agent
+    3. uv run python tests/test_flux.py
+
+模型切换:
+    FLUX_TEST_MODEL=flux-2-max uv run python tests/test_flux.py
+    (model 为路径段,如 flux-2-pro-preview、flux-2-pro、flux-dev 等,见官方 Available Endpoints)
+
+环境变量:
+    TOOL_AGENT_ROUTER_URL   默认 http://127.0.0.1:8001
+    FLUX_SUBMIT_TOOL_ID     默认 flux_submit
+    FLUX_QUERY_TOOL_ID      默认 flux_query
+    FLUX_TEST_MODEL         默认 flux-2-pro-preview
+    FLUX_TEST_PROMPT        覆盖默认短提示词
+    FLUX_POLL_INTERVAL_S    默认 1.0
+    FLUX_POLL_MAX_WAIT_S    默认 300
+"""
+
+from __future__ import annotations
+
+import io
+import os
+import sys
+import time
+from typing import Any
+
+if sys.platform == "win32":
+    _out = sys.stdout
+    if isinstance(_out, io.TextIOWrapper):
+        _out.reconfigure(encoding="utf-8")
+
+import httpx
+
+ROUTER_URL = os.environ.get("TOOL_AGENT_ROUTER_URL", "http://127.0.0.1:8001")
+SUBMIT_TOOL = os.environ.get("FLUX_SUBMIT_TOOL_ID", "flux_submit")
+QUERY_TOOL = os.environ.get("FLUX_QUERY_TOOL_ID", "flux_query")
+FLUX_MODEL = os.environ.get("FLUX_TEST_MODEL", "flux-2-pro-preview").strip()
+TEST_PROMPT = os.environ.get(
+    "FLUX_TEST_PROMPT",
+    "A tiny red apple on white background, simple product photo, minimal",
+)
+POLL_INTERVAL_S = float(os.environ.get("FLUX_POLL_INTERVAL_S", "1.0"))
+POLL_MAX_WAIT_S = float(os.environ.get("FLUX_POLL_MAX_WAIT_S", "300"))
+
+
+def run_tool(tool_id: str, params: dict[str, Any], timeout: float = 120.0) -> dict[str, Any]:
+    resp = httpx.post(
+        f"{ROUTER_URL}/run_tool",
+        json={"tool_id": tool_id, "params": params},
+        timeout=timeout,
+    )
+    resp.raise_for_status()
+    body = resp.json()
+    if body.get("status") != "success":
+        raise RuntimeError(body.get("error") or str(body))
+    result = body.get("result")
+    if isinstance(result, dict) and result.get("status") == "error":
+        raise RuntimeError(result.get("error", str(result)))
+    return result if isinstance(result, dict) else {}
+
+
+def _poll_terminal_success(data: dict[str, Any]) -> bool:
+    s = str(data.get("status") or "").strip()
+    return s.lower() == "ready"
+
+
+def _poll_terminal_failure(data: dict[str, Any]) -> bool:
+    s = str(data.get("status") or "").strip().lower()
+    return s in ("error", "failed")
+
+
+def _sample_url(data: dict[str, Any]) -> str | None:
+    r = data.get("result")
+    if isinstance(r, dict):
+        u = r.get("sample")
+        if isinstance(u, str) and u.startswith("http"):
+            return u
+    return None
+
+
+def main() -> None:
+    print("=" * 50)
+    print("测试 FLUX(BFL 异步 API + 模型可切换)")
+    print("=" * 50)
+    print(f"ROUTER_URL: {ROUTER_URL}")
+    print(f"model:      {FLUX_MODEL}")
+
+    try:
+        r = httpx.get(f"{ROUTER_URL}/health", timeout=3)
+        print(f"Router 状态: {r.json()}")
+    except httpx.ConnectError:
+        print(f"无法连接 Router ({ROUTER_URL}),请先: uv run python -m tool_agent")
+        sys.exit(1)
+
+    print("\n--- 校验工具已注册 ---")
+    tr = httpx.get(f"{ROUTER_URL}/tools", timeout=30)
+    tr.raise_for_status()
+    tools = tr.json().get("tools", [])
+    ids = {t["tool_id"] for t in tools}
+    for tid in (SUBMIT_TOOL, QUERY_TOOL):
+        if tid not in ids:
+            print(f"错误: {tid!r} 不在 GET /tools 中。示例 id: {sorted(ids)[:20]}...")
+            sys.exit(1)
+        meta = next(t for t in tools if t["tool_id"] == tid)
+        print(f"  {tid}: {meta.get('name', '')} (state={meta.get('state')})")
+
+    props = (next(t for t in tools if t["tool_id"] == SUBMIT_TOOL).get("input_schema") or {}).get(
+        "properties"
+    ) or {}
+    if "model" in props:
+        print("  flux_submit input_schema 已声明 model")
+    else:
+        print("  提示: flux_submit 宜在注册表中声明 model 以便切换端点")
+
+    print("\n--- flux_submit ---")
+    submit_params: dict[str, Any] = {
+        "model": FLUX_MODEL,
+        "prompt": TEST_PROMPT,
+        "width": 512,
+        "height": 512,
+    }
+    try:
+        sub = run_tool(SUBMIT_TOOL, submit_params, timeout=120.0)
+    except (RuntimeError, httpx.HTTPError) as e:
+        print(f"错误: {e}")
+        sys.exit(1)
+
+    print(f"提交返回 keys: {list(sub.keys())}")
+    req_id = sub.get("id") or sub.get("request_id")
+    poll_url = sub.get("polling_url")
+    if not req_id or not poll_url:
+        print(f"错误: 缺少 id 或 polling_url: {sub}")
+        sys.exit(1)
+    print(f"request id: {req_id}")
+    print(f"polling_url: {poll_url[:80]}...")
+
+    print("\n--- flux_query 轮询 ---")
+    deadline = time.monotonic() + POLL_MAX_WAIT_S
+    last: dict[str, Any] = {}
+
+    while time.monotonic() < deadline:
+        time.sleep(POLL_INTERVAL_S)
+        try:
+            last = run_tool(
+                QUERY_TOOL,
+                {"polling_url": str(poll_url), "request_id": str(req_id)},
+                timeout=60.0,
+            )
+        except (RuntimeError, httpx.HTTPError) as e:
+            print(f"轮询错误: {e}")
+            sys.exit(1)
+
+        st = last.get("status")
+        print(f"  status: {st}")
+
+        if _poll_terminal_failure(last):
+            print(f"生成失败: {last}")
+            sys.exit(1)
+        if _poll_terminal_success(last):
+            url = _sample_url(last)
+            if url:
+                print(f"\n图片 URL(signed,约 10 分钟内有效): {url[:100]}...")
+            print("\n测试通过!")
+            return
+
+    print(f"\n等待超时 ({POLL_MAX_WAIT_S}s),最后一次: {last}")
+    sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 7 - 0
tools/local/flux/.env.example

@@ -0,0 +1,7 @@
+# https://docs.bfl.ai/quick_start/generating_images
+BFL_API_KEY=
+
+# 可选,默认全球端点(文档要求使用响应里的 polling_url)
+BFL_API_BASE=https://api.bfl.ai/v1
+# BFL_API_BASE=https://api.eu.bfl.ai/v1
+# BFL_API_BASE=https://api.us.bfl.ai/v1

+ 3 - 0
tools/local/flux/.gitignore

@@ -0,0 +1,3 @@
+.env
+.venv/
+__pycache__/

+ 95 - 0
tools/local/flux/bfl_client.py

@@ -0,0 +1,95 @@
+"""BFL FLUX HTTP 客户端 — 异步提交 + 轮询。
+
+文档: https://docs.bfl.ai/quick_start/generating_images
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+import httpx
+from dotenv import load_dotenv
+
+_ = load_dotenv()
+
+DEFAULT_API_BASE = "https://api.bfl.ai/v1"
+
+
+def _api_key() -> str:
+    key = os.environ.get("BFL_API_KEY", "").strip()
+    if not key:
+        raise ValueError("缺少环境变量 BFL_API_KEY")
+    return key
+
+
+def _headers() -> dict[str, str]:
+    return {
+        "accept": "application/json",
+        "x-key": _api_key(),
+        "Content-Type": "application/json",
+    }
+
+
+def submit_generation(
+    *,
+    model: str,
+    prompt: str,
+    width: int | None = None,
+    height: int | None = None,
+    parameters: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    """POST {BFL_API_BASE}/{model},返回含 id、polling_url 等(以 BFL 响应为准)。"""
+    base = os.environ.get("BFL_API_BASE", DEFAULT_API_BASE).rstrip("/")
+    model_path = model.strip().lstrip("/")
+    url = f"{base}/{model_path}"
+
+    body: dict[str, Any] = dict(parameters) if parameters else {}
+    body["prompt"] = prompt
+    if width is not None:
+        body["width"] = width
+    if height is not None:
+        body["height"] = height
+
+    with httpx.Client(timeout=120.0) as client:
+        r = client.post(url, headers=_headers(), json=body)
+        try:
+            data = r.json()
+        except Exception:
+            r.raise_for_status()
+            raise RuntimeError(r.text[:2000]) from None
+
+    if r.status_code >= 400:
+        err = data.get("detail") if isinstance(data, dict) else None
+        msg = err if err is not None else str(data)
+        raise RuntimeError(f"BFL HTTP {r.status_code}: {msg}")
+
+    if not isinstance(data, dict):
+        raise RuntimeError("提交响应不是 JSON 对象")
+    return data
+
+
+def poll_result(*, polling_url: str, request_id: str) -> dict[str, Any]:
+    """GET polling_url,Query: id=request_id(与官方示例一致)。"""
+    with httpx.Client(timeout=60.0) as client:
+        r = client.get(
+            polling_url.strip(),
+            headers={
+                "accept": "application/json",
+                "x-key": _api_key(),
+            },
+            params={"id": request_id.strip()},
+        )
+        try:
+            data = r.json()
+        except Exception:
+            r.raise_for_status()
+            raise RuntimeError(r.text[:2000]) from None
+
+    if r.status_code >= 400:
+        msg = data if isinstance(data, dict) else str(data)
+        raise RuntimeError(f"BFL poll HTTP {r.status_code}: {msg}")
+
+    if not isinstance(data, dict):
+        raise RuntimeError("轮询响应不是 JSON 对象")
+    return data

+ 87 - 0
tools/local/flux/main.py

@@ -0,0 +1,87 @@
+"""BFL FLUX 本地封装 — 异步生图(提交 + 轮询)。
+
+环境变量:
+  BFL_API_KEY   必填,请求头 x-key
+  BFL_API_BASE  可选,默认 https://api.bfl.ai/v1(全球端点;也可用 api.eu.bfl.ai/v1 等)
+
+接口:
+  GET  /health
+  POST /submit  提交生成;body.model 为端点路径段,如 flux-2-pro-preview
+  POST /query   轮询;polling_url、request_id 来自提交响应
+
+文档: https://docs.bfl.ai/quick_start/generating_images
+"""
+
+from __future__ import annotations
+
+import argparse
+from typing import Any
+
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, Field
+
+from bfl_client import poll_result, submit_generation
+
+app = FastAPI(title="BFL FLUX API Proxy")
+
+
+class SubmitRequest(BaseModel):
+    model: str = Field(
+        ...,
+        description="模型端点路径段,如 flux-2-pro-preview、flux-2-max、flux-dev(对应 /v1/{model})",
+    )
+    prompt: str = Field(..., description="文生图提示词")
+    width: int | None = Field(default=None, description="输出宽度(像素)")
+    height: int | None = Field(default=None, description="输出高度(像素)")
+    parameters: dict[str, Any] | None = Field(
+        default=None,
+        description="合并进请求体的额外字段(官方各模型可选参数)",
+    )
+
+
+class QueryRequest(BaseModel):
+    polling_url: str = Field(..., description="提交响应中的 polling_url,须原样使用")
+    request_id: str = Field(..., description="提交响应中的 id")
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.post("/submit")
+def submit(req: SubmitRequest) -> dict[str, Any]:
+    try:
+        return submit_generation(
+            model=req.model,
+            prompt=req.prompt,
+            width=req.width,
+            height=req.height,
+            parameters=req.parameters,
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+@app.post("/query")
+def query(req: QueryRequest) -> dict[str, Any]:
+    try:
+        return poll_result(polling_url=req.polling_url, request_id=req.request_id)
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=8001)
+    args = parser.parse_args()
+    uvicorn.run(app, host="0.0.0.0", port=args.port)

+ 12 - 0
tools/local/flux/pyproject.toml

@@ -0,0 +1,12 @@
+[project]
+name = "bfl-flux"
+version = "0.1.0"
+description = "BFL FLUX 异步生图:POST /submit、/query(x-key + polling_url)"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.115.0",
+    "uvicorn>=0.30.0",
+    "pydantic>=2.0.0",
+    "python-dotenv>=1.0.0",
+    "httpx>=0.27.0",
+]