Files
Van/攻城狮鹿鸣君 92a1b50f91 isolate providers before expanding MediaHub integrations
Replace provider-switching task nodes with provider-specific config nodes and stable generic image/video execution contracts. Add Agnes Image 2.1 and Video 2.0 using behavior verified against the live API.

Constraint: Old workflows are intentionally unsupported; Agnes Image 2.0 remains documentation-only.

Rejected: Single task nodes with provider dropdowns | provider-specific union schemas create serialization risk and cross-provider coupling.

Confidence: high

Scope-risk: broad

Directive: New providers must register isolated operation adapters and reuse generic execution nodes without modifying existing provider packages.

Tested: 120 pytest tests; Python compileall; JavaScript syntax; 16 workflow JSON validations; restarted ComfyUI smoke; live Agnes Image 2.1 T2I/I2I; live Agnes Video 2.0 T2V completion and I2V Data URI submission.

Not-tested: User-driven visual inspection of every imported UI workflow and full completed Agnes I2V download.
2026-06-13 17:49:31 +08:00

176 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Validate MediaHub's live ComfyUI node schema and API workflow alignment."""
import argparse
import json
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[1]
WORKFLOWS = sorted((ROOT / "tests" / "fixtures").glob("*_prompt.json"))
API_ENV = ROOT / ".api_env"
def request_json(url: str, payload: dict | None = None) -> dict:
data = None if payload is None else json.dumps(payload).encode()
request = Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="GET" if payload is None else "POST",
)
with urlopen(request, timeout=10) as response:
return json.load(response)
def load_api_env(path: Path) -> dict[str, str]:
values = {}
if not path.exists():
return values
for line_number, raw_line in enumerate(path.read_text().splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise RuntimeError(f"Invalid {path.name} line {line_number}")
name, value = line.split("=", 1)
values[name.strip()] = value.strip().strip("'\"")
return values
def wait_for_prompt(base_url: str, prompt_id: str, timeout: int = 180) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
history = request_json(f"{base_url}/history/{prompt_id}")
result = history.get(prompt_id)
if result:
return result
time.sleep(1)
raise RuntimeError(f"Timed out waiting for ComfyUI prompt {prompt_id}")
def validate_workflow(
base_url: str,
workflow_path: Path,
node_classes: list[str],
api_key: str = "",
) -> dict:
"""Validate that a fixture's node inputs match the live ComfyUI schema."""
workflow = json.loads(workflow_path.read_text())
if api_key:
for node in workflow.values():
if node["class_type"] == "MediaHubAPIKey":
node["inputs"]["api_key"] = api_key
for node_class in node_classes:
object_info = request_json(f"{base_url}/object_info/{node_class}")
live_inputs = object_info[node_class]["input"]
live_required = set(live_inputs["required"])
live_accepted = live_required | set(live_inputs.get("optional") or {})
target = next(
node for node in workflow.values() if node["class_type"] == node_class
)
example_required = set(target["inputs"])
if not live_required <= example_required or not example_required <= live_accepted:
missing = sorted(live_required - example_required)
extra = sorted(example_required - live_accepted)
raise RuntimeError(
f"{node_class} API workflow/schema mismatch: "
f"missing={missing}, extra={extra}"
)
result = request_json(f"{base_url}/prompt", {"prompt": workflow})
node_errors = result.get("node_errors") or {}
if node_errors:
raise RuntimeError(
f"ComfyUI rejected {workflow_path.name}:\n"
+ json.dumps(node_errors, ensure_ascii=False, indent=2)
)
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument(
"--live",
action="store_true",
help="Use MINIMAX_CN_API_KEY from .api_env and wait for a real result.",
)
args = parser.parse_args()
base_url = args.base_url.rstrip("/")
try:
env = {}
if args.live:
env = load_api_env(API_ENV)
results = []
for workflow_path in WORKFLOWS:
workflow = json.loads(workflow_path.read_text())
node_classes = sorted(
{
node["class_type"]
for node in workflow.values()
if node["class_type"].startswith(("MediaHub", "MiniMax", "Agnes"))
}
)
api_key = ""
if args.live:
key_name = (
"AGNES_API_KEY"
if workflow_path.name.startswith("agnes_")
else "MINIMAX_CN_API_KEY"
)
api_key = env.get(key_name, "")
if not api_key:
raise RuntimeError(f"{key_name} is empty or missing in .api_env")
results.append(
(workflow_path.stem, validate_workflow(base_url, workflow_path, node_classes, api_key))
)
if args.live:
for label, result in results:
prompt_id = result.get("prompt_id")
if not prompt_id:
raise RuntimeError(f"ComfyUI did not return a prompt_id for {label}")
history = wait_for_prompt(base_url, prompt_id)
status = history.get("status") or {}
if status.get("status_str") != "success":
messages = status.get("messages") or []
raise RuntimeError(
f"Live {label} prompt failed:\n"
+ json.dumps(messages, ensure_ascii=False, indent=2)
)
outputs = history.get("outputs") or {}
if not outputs:
raise RuntimeError(f"Live {label} prompt completed without outputs")
except (HTTPError, URLError, KeyError, RuntimeError) as exc:
print(f"FAIL: {exc}")
return 1
print(
"PASS: live node schemas match API examples and ComfyUI accepted "
"all prompt structures."
)
if args.live:
print(
"PASS: live fixture requests completed successfully."
)
else:
print(
"Queued jobs are expected to stop at an empty provider API key; "
"no paid API request is made."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())