OpenAI-compatible adapter for ComfyUI image generation and editing

This commit is contained in:
Shaman
2026-10-04 21:56:00 +03:00
commit c17ab5036e
29 changed files with 1730 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.venv
__pycache__
*.pyc
.pytest_cache
.env
tests
*.md
+10
View File
@@ -0,0 +1,10 @@
# ComfyUI server (required)
COMFY_BASE_URL=http://192.168.0.3:8188
# Static bearer key clients must send (required)
ADAPTER_API_KEY=sk-1234
# Seconds to wait for ComfyUI execution result
EXECUTION_TIMEOUT=300
# Poll interval for /history, seconds
POLL_INTERVAL=1.5
# Optional: JSON file overriding node mappings (see app/config.py defaults)
# MAPPING_FILE=mapping.json
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
.env
.pytest_cache/
*.egg-info/
dist/
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY pyproject.toml ./
COPY app ./app
COPY workflow_create.json workflow_edit.json ./
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+134
View File
@@ -0,0 +1,134 @@
# comfy-adapter
OpenAI-совместимый адаптер поверх ComfyUI: `POST /v1/images/generations` (генерация,
модель `z-image-turbo`) и `POST /v1/images/edits` (редактирование, модель
`qwen-image-edit`). Адаптер подставляет параметры запроса в шаблоны workflow,
отправляет их в ComfyUI, дождаться выполнения и возвращает изображение как
`data[0].b64_json` (base64 PNG).
```
Client --(OpenAI contract)--> comfy-adapter --(ComfyUI HTTP API)--> ComfyUI
/v1/images/generations POST /prompt
/v1/images/edits POST /upload/image
GET /history/{id}
GET /view
```
## Запуск
```bash
python -m venv .venv && .venv/bin/pip install -e . # Windows: .venv\Scripts\
cp .env.example .env # заполните COMFY_BASE_URL, ADAPTER_API_KEY
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Или Docker:
```bash
docker build -t comfy-adapter .
docker run -p 8000:8000 --env-file .env comfy-adapter
```
При старте адаптер загружает `workflow_create.json` / `workflow_edit.json`, локально
проверяет графы на битые ссылки (битый шаблон — падение старта со списком проблемных
узлов) и прогоняет серверную проверку `POST /prompt?front=false` (отключается:
`VALIDATE_TEMPLATES_ON_SERVER=false`). Логи стартовой проверки видны с
`uvicorn --log-level info`.
## Переменные окружения
| Переменная | Default | Назначение |
|---|---|---|
| `COMFY_BASE_URL` | `http://192.168.0.3:8188` | Адрес ComfyUI |
| `ADAPTER_API_KEY` | — (обязательна) | Статический Bearer-ключ клиентов |
| `EXECUTION_TIMEOUT` | `300` | Секунд ожидания результата ComfyUI |
| `POLL_INTERVAL` | `1.5` | Интервал опроса `/history`, сек |
| `MAPPING_FILE` | — | JSON-файл, переопределяющий карту маппинга |
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Проверять шаблоны на сервере при старте |
## Контракт
`POST /v1/images/generations` (JSON, `Authorization: Bearer <ADAPTER_API_KEY>`):
```json
{"prompt": "a red cube", "model": "z-image-turbo", "n": 1, "size": "1024x1024", "seed": 123}
```
- `n` поддерживается только 1 (иначе 400); `seed` необязателен — без него случайный
(повтор с тем же seed + prompt даёт идентичное изображение).
- Ответ: `{"created": <unix>, "data": [{"b64_json": "<base64 PNG>"}]}`.
`POST /v1/images/edits` — два транспорта:
- multipart: поля `image` (файл PNG/JPEG) + `prompt` (+ необязательные `model`, `seed`);
- JSON: `{"image": "<base64>", "prompt": "...", "model": "qwen-image-edit", "seed": 5}`.
Поле `size` в edits принимается, но не влияет на выход: итоговый размер — ~1 Мп с
аспектом входного изображения (это делает сам workflow).
Ошибки — в формате OpenAI (`error.message/type/param/code`): `400` — невалидное тело;
`401` — ключ отсутствует/неверен; `502` — ComfyUI недоступен, отверг workflow
(`comfyui_validation_failed`), ошибко исполнения (`comfyui_execution_failed`) или таймаут
(`comfyui_timeout`). `GET /health` — без ключа.
```bash
# генерация
curl -s http://localhost:8000/v1/images/generations \
-H "Authorization: Bearer $ADAPTER_API_KEY" -H "Content-Type: application/json" \
-d '{"prompt":"a red cube on white background","size":"1024x1024","seed":42}' | jq -r .data[0].b64_json | base64 -d > out.png
# редактирование
curl -s http://localhost:8000/v1/images/edits \
-H "Authorization: Bearer $ADAPTER_API_KEY" \
-F image=@input.png -F prompt="make the background deep blue" | jq -r .data[0].b64_json | base64 -d > edited.png
```
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="sk-...")
r = client.images.generate(model="z-image-turbo", prompt="a red cube", size="1024x1024", n=1)
open("out.png", "wb").write(__import__("base64").b64decode(r.data[0].b64_json))
```
## Карта маппинга
Параметр -> узел workflow (правится без кода через `MAPPING_FILE`):
| Параметр | workflow_create.json | workflow_edit.json |
|---|---|---|
| prompt | `83:27.inputs.text` | `115:111.inputs.prompt` |
| negative | — (ConditioningZeroOut) | `115:110.inputs.prompt` (всегда "") |
| size | `83:13.inputs.width/height` | — (размер = ~1 Мп входа) |
| seed | `83:3.inputs.seed` | `115:3.inputs.seed` |
| image | — | upload -> `78.inputs.image` |
## Починка шаблонов относительно экспорта OpenWebUI
Выгруженные из OpenWebUI шаблоны **не проходили валидацию ComfyUI** (`KeyError` по
несуществующим узлам). Исправления в репозиторных файлах:
- `workflow_create.json`: ссылки `[83:43, 0]`/`[83:43, 1]` (узла нет в графе)
переподключены на существующие лоадеры: `83:3.inputs.model -> [83:28, 0]`
(UNETLoader z_image_turbo), `83:27.inputs.clip -> [83:30, 0]` (CLIPLoader qwen_3_4b).
- `workflow_edit.json`: `115:66.inputs.model -> [115:89, 0]` вместо несуществующего
`115:117` — LoRA `Qwen-Image-Edit-2509-Lightning-4steps` (нода `115:89`, в экспорте
«висячая») подключена в цепочку модели.
Оба шаблона проходят `POST /prompt?front=false` и реально генерируют (проверено на
ComfyUI 0.37.0 / V100).
## Замечания по продакшену
- Ответ синхронный: соединение висит до конца генерации. За reverse proxy поднимите
`proxy_read_timeout` (nginx) / эквивалент выше, чем `EXECUTION_TIMEOUT`.
- Фоновые интеграции (OpenWebUI) продолжают работать с ComfyUI напрямую — адаптер
ничего не migрирует.
## Тесты
```bash
pip install -e ".[dev]"
pytest # unit + mocked-ComfyUI (respx)
pytest -m live # живые прогоны: нужен запущенный адаптер и ADAPTER_LIVE=1,
# ADAPTER_URL=http://127.0.0.1:8000 ADAPTER_KEY=<ключ>
```
View File
+15
View File
@@ -0,0 +1,15 @@
"""Static bearer-key authentication."""
import secrets
from fastapi import Request
from .config import Settings
from .errors import Unauthorized
def require_key(request: Request, settings: Settings) -> None:
header = request.headers.get("authorization", "")
scheme, _, token = header.partition(" ")
expected = settings.adapter_api_key
if not expected or scheme.lower() != "bearer" or not secrets.compare_digest(token, expected):
raise Unauthorized()
+124
View File
@@ -0,0 +1,124 @@
"""Async HTTP client for the ComfyUI server (official endpoints only)."""
import asyncio
import json
import secrets
import time
import uuid
from typing import Any
import httpx
from .errors import ComfyUIError
class ComfyClient:
def __init__(self, base_url: str, timeout: float = 30.0):
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
async def aclose(self) -> None:
await self._client.aclose()
async def submit_prompt(self, graph: dict[str, Any]) -> str:
"""Queue a graph; returns prompt_id. ComfyUI validates synchronously."""
payload = {"prompt": graph, "client_id": str(uuid.uuid4())}
try:
resp = await self._client.post("/prompt", json=payload)
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code == 400:
detail = _validation_detail(resp.json())
raise ComfyUIError(f"ComfyUI rejected workflow: {detail}", code="comfyui_validation_failed")
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /prompt returned HTTP {resp.status_code}")
prompt_id = resp.json().get("prompt_id")
if not prompt_id:
raise ComfyUIError("ComfyUI /prompt response missing prompt_id")
return prompt_id
async def upload_image(self, data: bytes, content_type: str = "image/png") -> str:
"""Upload an input image; returns the server-side filename."""
filename = f"adapter_{uuid.uuid4().hex[:8]}_{secrets.token_hex(4)}.png"
try:
resp = await self._client.post(
"/upload/image",
files={"image": (filename, data, content_type)},
)
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /upload/image returned HTTP {resp.status_code}")
name = resp.json().get("name")
if not name:
raise ComfyUIError("ComfyUI /upload/image response missing name")
return name
async def wait_for_result(self, prompt_id: str, timeout: float, interval: float) -> dict:
"""Poll /history/{prompt_id} until the entry appears; returns the history entry."""
deadline = time.monotonic() + timeout
while True:
try:
resp = await self._client.get(f"/history/{prompt_id}")
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /history returned HTTP {resp.status_code}")
entry = resp.json().get(prompt_id)
if entry is not None:
status = entry.get("status", {})
if status.get("status_str") == "error" or status.get("status_str") == "execution_failed":
raise ComfyUIError(f"ComfyUI execution failed for prompt {prompt_id}", code="comfyui_execution_failed")
return entry
if time.monotonic() >= deadline:
raise ComfyUIError(
f"Timed out after {timeout:.0f}s waiting for prompt {prompt_id}",
code="comfyui_timeout",
)
await asyncio.sleep(interval)
async def fetch_view(self, filename: str, subfolder: str = "") -> bytes:
"""Download a produced image from the output folder."""
try:
resp = await self._client.get(
"/view", params={"filename": filename, "subfolder": subfolder, "type": "output"}
)
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code != 200:
raise ComfyUIError(f"ComfyUI /view returned HTTP {resp.status_code} for '{filename}'")
return resp.content
async def validate_graph(self, graph: dict[str, Any]) -> None:
"""Validate a graph without queueing (POST /prompt?front=false)."""
try:
resp = await self._client.post("/prompt", params={"front": "false"}, json={"prompt": graph})
except httpx.HTTPError as e:
raise ComfyUIError(f"ComfyUI unreachable: {e}") from e
if resp.status_code == 200:
return
if resp.status_code == 400:
raise ComfyUIError(
f"ComfyUI rejected workflow: {_validation_detail(resp.json())}",
code="comfyui_validation_failed",
)
raise ComfyUIError(f"ComfyUI /prompt?front=false returned HTTP {resp.status_code}")
def extract_output_image(history_entry: dict) -> tuple[str, str]:
"""(filename, subfolder) of the first output image, preferring SaveImage nodes."""
outputs = history_entry.get("outputs", {})
images: list[dict] = []
for node_output in outputs.values():
images.extend(img for img in node_output.get("images", []) if img.get("type") == "output")
if not images:
raise ComfyUIError("ComfyUI produced no output images", code="comfyui_no_output")
return images[0]["filename"], images[0].get("subfolder", "")
def _validation_detail(body: Any) -> str:
if isinstance(body, dict) and "node_errors" in body:
parts = []
for node_id, node_err in body["node_errors"].items():
for err in node_err.get("errors", []):
parts.append(f"node {node_id}: {err.get('message')} {err.get('details', '')}".strip())
return "; ".join(parts) or "validation failed"
return json.dumps(body)[:300] if body else "validation failed"
+55
View File
@@ -0,0 +1,55 @@
"""Environment settings and the parameter -> workflow node mapping."""
import json
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
REPO_ROOT = Path(__file__).resolve().parent.parent
CREATE_MODEL = "z-image-turbo"
EDIT_MODEL = "qwen-image-edit"
AVAILABLE_MODELS = [CREATE_MODEL, EDIT_MODEL]
# Node ids refer to workflow_create.json / workflow_edit.json.
# Override the whole map via MAPPING_FILE (JSON with "create" and "edit" keys).
DEFAULT_MAPPING: dict[str, dict[str, dict[str, str]]] = {
"create": {
"prompt": {"node": "83:27", "field": "text"},
"size": {"node": "83:13", "width_field": "width", "height_field": "height"},
"seed": {"node": "83:3", "field": "seed"},
},
"edit": {
"prompt": {"node": "115:111", "field": "prompt"},
"negative": {"node": "115:110", "field": "prompt"},
"seed": {"node": "115:3", "field": "seed"},
"image": {"node": "78", "field": "image"},
},
}
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
comfy_base_url: str = "http://192.168.0.3:8188"
adapter_api_key: str = ""
execution_timeout: float = 300.0
poll_interval: float = 1.5
create_template_path: Path = REPO_ROOT / "workflow_create.json"
edit_template_path: Path = REPO_ROOT / "workflow_edit.json"
mapping_file: Path | None = None
# Validate graphs on the ComfyUI server (POST /prompt?front=false) at startup.
validate_templates_on_server: bool = True
def mapping(self) -> dict[str, dict[str, dict[str, str]]]:
if self.mapping_file is None:
return DEFAULT_MAPPING
with open(self.mapping_file, encoding="utf-8") as f:
loaded = json.load(f)
merged = {k: dict(v) for k, v in DEFAULT_MAPPING.items()}
for section, section_map in loaded.items():
merged.setdefault(section, {}).update(section_map)
return merged
def get_settings() -> Settings:
return Settings()
+41
View File
@@ -0,0 +1,41 @@
"""Adapter exceptions and OpenAI-shaped error responses."""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AdapterError(Exception):
"""Base class; renders as an OpenAI error body."""
def __init__(self, status: int, message: str, err_type: str, code: str | None = None):
self.status = status
self.message = message
self.err_type = err_type
self.code = code
super().__init__(message)
class InvalidRequest(AdapterError):
def __init__(self, message: str, code: str | None = None):
super().__init__(400, message, "invalid_request_error", code)
class Unauthorized(AdapterError):
def __init__(self, message: str = "Invalid API key provided"):
super().__init__(401, message, "invalid_request_error", "invalid_api_key")
class ComfyUIError(AdapterError):
"""ComfyUI rejected validation, failed execution, or timed out."""
def __init__(self, message: str, code: str = "comfyui_error"):
super().__init__(502, message, "server_error", code)
def error_body(message: str, err_type: str, code: str | None = None) -> dict:
return {"error": {"message": message, "type": err_type, "param": None, "code": code}}
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AdapterError)
async def adapter_error_handler(request: Request, exc: AdapterError) -> JSONResponse:
return JSONResponse(status_code=exc.status, content=error_body(exc.message, exc.err_type, exc.code))
+67
View File
@@ -0,0 +1,67 @@
"""Build a concrete workflow graph from a template + request parameters."""
import copy
import secrets
from typing import Any
from .errors import InvalidRequest
SEED_MAX = 2**63 - 1
def parse_size(size: str) -> tuple[int, int]:
"""Parse OpenAI `WxH` size strings (e.g. '1024x1024')."""
try:
w_str, h_str = size.lower().split("x", 1)
width, height = int(w_str), int(h_str)
except ValueError as e:
raise InvalidRequest(f"invalid size '{size}', expected format 'WIDTHxHEIGHT'") from e
if width <= 0 or height <= 0:
raise InvalidRequest(f"invalid size '{size}', dimensions must be positive")
return width, height
def new_seed(seed: int | None) -> int:
if seed is None:
return secrets.randbelow(SEED_MAX) + 1
if not 0 <= seed <= SEED_MAX:
raise InvalidRequest(f"seed must be between 0 and {SEED_MAX}")
return seed
def build_workflow(
template: dict[str, Any],
mapping: dict[str, dict[str, str]],
*,
prompt: str,
seed: int,
size: str | None = None,
image_name: str | None = None,
negative: str = "",
) -> dict[str, Any]:
"""Deep-copy the template and inject request values per the node mapping."""
graph = copy.deepcopy(template)
def put(key: str, value: Any) -> None:
spec = mapping.get(key)
if spec is None:
return
node = graph.get(spec["node"])
if node is None:
raise InvalidRequest(f"mapping key '{key}' points to missing node '{spec['node']}'")
node["inputs"][spec["field"]] = value
put("prompt", prompt)
put("negative", negative)
put("seed", seed)
if image_name is not None:
put("image", image_name)
if size is not None:
spec = mapping.get("size")
if spec is not None:
width, height = parse_size(size)
node = graph.get(spec["node"])
if node is None:
raise InvalidRequest(f"mapping key 'size' points to missing node '{spec['node']}'")
node["inputs"][spec["width_field"]] = width
node["inputs"][spec["height_field"]] = height
return graph
+171
View File
@@ -0,0 +1,171 @@
"""FastAPI application: OpenAI-compatible image endpoints backed by ComfyUI."""
import base64
import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from .auth import require_key
from .comfy_client import ComfyClient, extract_output_image
from .config import AVAILABLE_MODELS, CREATE_MODEL, EDIT_MODEL, Settings, get_settings
from .errors import InvalidRequest, install_error_handlers
from .injector import build_workflow, new_seed
from .schemas import (
ImageEditJsonRequest,
ImageGenerationRequest,
ImageItem,
ImagesResponse,
ModelCard,
ModelsResponse,
)
from .templates import load_all
logger = logging.getLogger("comfy-adapter")
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
JPEG_MAGIC = b"\xff\xd8\xff"
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
create_graph, edit_graph = load_all(settings)
client = ComfyClient(settings.comfy_base_url)
if settings.validate_templates_on_server:
try:
await client.validate_graph(create_graph)
await client.validate_graph(edit_graph)
logger.info("workflow templates validated on ComfyUI server")
except Exception as e: # noqa: BLE001 - startup check is advisory, logged
logger.error("server-side template validation failed: %s", e)
app.state.settings = settings
app.state.comfy = client
app.state.templates = {"create": create_graph, "edit": edit_graph}
logger.info("comfy-adapter ready (server=%s)", settings.comfy_base_url)
try:
yield
finally:
await client.aclose()
app = FastAPI(title="comfy-adapter", lifespan=lifespan)
install_error_handlers(app)
def _s(req: Request) -> Settings:
return req.app.state.settings
async def _run(graph: dict) -> ImagesResponse:
comfy: ComfyClient = app.state.comfy
cfg: Settings = app.state.settings
prompt_id = await comfy.submit_prompt(graph)
entry = await comfy.wait_for_result(prompt_id, cfg.execution_timeout, cfg.poll_interval)
filename, subfolder = extract_output_image(entry)
image_bytes = await comfy.fetch_view(filename, subfolder)
return ImagesResponse(
created=int(time.time()),
data=[ImageItem(b64_json=base64.b64encode(image_bytes).decode())],
)
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@app.get("/v1/models")
async def models(request: Request) -> ModelsResponse:
require_key(request, _s(request))
return ModelsResponse(data=[ModelCard(id=m) for m in AVAILABLE_MODELS])
@app.post("/v1/images/generations")
async def generations(request: Request) -> ImagesResponse:
settings = _s(request)
require_key(request, settings)
body = _parse_json_body(await request.body(), ImageGenerationRequest)
if not body.prompt.strip():
raise InvalidRequest("prompt must not be empty")
if body.n not in (None, 1):
raise InvalidRequest("only n=1 is supported")
if body.model is not None and body.model != CREATE_MODEL:
raise InvalidRequest(f"unknown model '{body.model}', expected '{CREATE_MODEL}'")
graph = build_workflow(
app.state.templates["create"],
settings.mapping()["create"],
prompt=body.prompt,
seed=new_seed(body.seed),
size=body.size,
)
return await _run(graph)
@app.post("/v1/images/edits")
async def edits(request: Request) -> ImagesResponse:
settings = _s(request)
require_key(request, settings)
content_type = request.headers.get("content-type", "")
if content_type.startswith("multipart/form-data"):
image_bytes, prompt, model, seed = await _parse_multipart(request)
elif content_type.startswith("application/json"):
body = _parse_json_body(await request.body(), ImageEditJsonRequest)
image_bytes = base64.b64decode(body.image)
prompt, model, seed = body.prompt, body.model, body.seed
else:
raise InvalidRequest("expected multipart/form-data or application/json body")
if not prompt.strip():
raise InvalidRequest("prompt must not be empty")
if model is not None and model != EDIT_MODEL:
raise InvalidRequest(f"unknown model '{model}' for edits, expected '{EDIT_MODEL}'")
_validate_image_bytes(image_bytes)
comfy: ComfyClient = app.state.comfy
name = await comfy.upload_image(image_bytes)
graph = build_workflow(
app.state.templates["edit"],
settings.mapping()["edit"],
prompt=prompt,
seed=new_seed(seed),
image_name=name,
negative="",
)
return await _run(graph)
return app
def _parse_json_body(raw: bytes, model_cls):
from .errors import InvalidRequest
import json
try:
data = json.loads(raw or b"{}")
except json.JSONDecodeError as e:
raise InvalidRequest(f"invalid JSON body: {e}") from e
try:
return model_cls.model_validate(data)
except Exception as e:
raise InvalidRequest(f"invalid request body: {e}") from e
async def _parse_multipart(request: Request):
form = await request.form()
upload = form.get("image")
# duck-typing: FastAPI 0.142+ ships its own UploadFile class, forms yield starlette's
if upload is None or not hasattr(upload, "read"):
raise InvalidRequest("field 'image' (file) is required")
image_bytes = await upload.read()
prompt = str(form.get("prompt") or "")
model = form.get("model")
seed_raw = form.get("seed")
try:
seed = int(seed_raw) if seed_raw not in (None, "") else None
except (TypeError, ValueError):
raise InvalidRequest("seed must be an integer")
return image_bytes, prompt, (str(model) if model else None), seed
def _validate_image_bytes(data: bytes) -> None:
if not data:
raise InvalidRequest("image is empty")
if not (data.startswith(PNG_MAGIC) or data.startswith(JPEG_MAGIC)):
raise InvalidRequest("image must be PNG or JPEG")
app = create_app()
+62
View File
@@ -0,0 +1,62 @@
"""Pydantic models for the OpenAI images contract."""
import base64
import binascii
from pydantic import BaseModel, field_validator
DEFAULT_SIZE = "1024x1024"
class ImageGenerationRequest(BaseModel):
prompt: str
model: str | None = None
n: int | None = 1
size: str = DEFAULT_SIZE
seed: int | None = None
response_format: str | None = None # accepted; only b64_json is produced
@field_validator("size")
@classmethod
def _size_shape(cls, v: str) -> str:
w_str, sep, h_str = v.lower().partition("x")
if not sep or not w_str.isdigit() or not h_str.isdigit() or int(w_str) <= 0 or int(h_str) <= 0:
raise ValueError(f"invalid size '{v}', expected format 'WIDTHxHEIGHT'")
return v
class ImageEditJsonRequest(BaseModel):
image: str # base64-encoded PNG/JPEG (data-URL prefix allowed)
prompt: str
model: str | None = None
size: str | None = None # accepted but ignored: edits size comes from the input image
seed: int | None = None
@field_validator("image")
@classmethod
def _decode_b64(cls, v: str) -> str:
raw = v.split(",", 1)[1] if v.startswith("data:") else v
try:
base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError) as e:
raise ValueError("image must be valid base64") from e
return raw
class ImageItem(BaseModel):
b64_json: str
class ImagesResponse(BaseModel):
created: int
data: list[ImageItem]
class ModelCard(BaseModel):
id: str
object: str = "model"
owned_by: str = "comfy-adapter"
class ModelsResponse(BaseModel):
object: str = "list"
data: list[ModelCard]
+62
View File
@@ -0,0 +1,62 @@
"""Template loading with startup validation of graph links and mappings."""
import json
from pathlib import Path
from .config import Settings
from .errors import AdapterError
class TemplateError(AdapterError):
"""Malformed workflow template; blocks startup."""
def __init__(self, message: str):
super().__init__(500, message, "server_error", "template_error")
def find_broken_links(graph: dict) -> list[str]:
"""References to node ids that do not exist in the graph."""
errors = []
for node_id, node in graph.items():
for field, value in node.get("inputs", {}).items():
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str):
if value[0] not in graph:
errors.append(f"node {node_id}: input '{field}' links to missing node '{value[0]}'")
return errors
def check_mapping(graph: dict, mapping: dict, name: str) -> list[str]:
"""Every mapped node id must exist with a scalar input field."""
errors = []
for key, spec in mapping.items():
node = graph.get(spec["node"])
if node is None:
errors.append(f"mapping '{name}.{key}': node '{spec['node']}' not found in template")
continue
for field in _mapped_fields(spec):
if field not in node.get("inputs", {}) or isinstance(node["inputs"][field], list):
errors.append(f"mapping '{name}.{key}': node '{spec['node']}' has no scalar input '{field}'")
return errors
def _mapped_fields(spec: dict) -> list[str]:
if "width_field" in spec:
return [spec["width_field"], spec["height_field"]]
return [spec["field"]]
def load_template(path: Path, graph_name: str, mapping: dict, mapping_name: str) -> dict:
with open(path, encoding="utf-8") as f:
graph = json.load(f)
errors = find_broken_links(graph) + check_mapping(graph, mapping[mapping_name], mapping_name)
if errors:
raise TemplateError(f"workflow template '{graph_name}' ({path}) is invalid:\n " + "\n ".join(errors))
return graph
def load_all(settings: Settings) -> tuple[dict, dict]:
mapping = settings.mapping()
create = load_template(
settings.create_template_path, "workflow_create.json", mapping, "create"
)
edit = load_template(settings.edit_template_path, "workflow_edit.json", mapping, "edit")
return create, edit
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "comfy-adapter"
version = "0.1.0"
description = "OpenAI-compatible adapter for ComfyUI image generation and editing"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"httpx>=0.27",
"pydantic>=2.7",
"pydantic-settings>=2.3",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"pytest-asyncio>=0.23",
"respx>=0.21",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
markers = [
"live: tests against the real ComfyUI server (manual run)",
]
[tool.setuptools]
packages = ["app"]
View File
+51
View File
@@ -0,0 +1,51 @@
import struct
import zlib
import httpx
import pytest
from app.config import Settings
from app.main import create_app
COMFY = "http://comfy.test"
FAKE_KEY = "sk-test-1234"
def settings(**overrides) -> Settings:
base = dict(
comfy_base_url=COMFY,
adapter_api_key=FAKE_KEY,
validate_templates_on_server=False,
poll_interval=0.0,
execution_timeout=5.0,
)
base.update(overrides)
return Settings(**base)
def png_bytes(w: int = 64, h: int = 64) -> bytes:
def chunk(t: bytes, d: bytes) -> bytes:
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
iend = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(b"\x00" * (w * 3 + 1) * h)) + chunk(b"IEND", b"")
return iend
def png_size(data: bytes) -> tuple[int, int]:
return struct.unpack(">II", data[16:24])
@pytest.fixture
async def client():
"""Async client bound to the app with lifespan running
(templates loaded, comfy client targeting http://comfy.test)."""
import contextlib
app = create_app(settings())
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(app.router.lifespan_context(app))
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://adapter.test") as ac:
ac.headers["Authorization"] = f"Bearer {FAKE_KEY}"
yield ac
+26
View File
@@ -0,0 +1,26 @@
import httpx
import pytest
from tests.conftest import FAKE_KEY
async def test_missing_key_401(client: httpx.AsyncClient):
r = await client.post("/v1/images/generations", json={"prompt": "x"}, headers={"Authorization": ""})
assert r.status_code == 401
assert r.json()["error"]["code"] == "invalid_api_key"
async def test_wrong_key_401(client: httpx.AsyncClient):
r = await client.post("/v1/images/generations", json={"prompt": "x"}, headers={"Authorization": "Bearer sk-wrong"})
assert r.status_code == 401
async def test_valid_key_accepted(client: httpx.AsyncClient):
r = await client.get("/v1/models")
assert r.status_code == 200
async def test_health_open_without_key(client: httpx.AsyncClient):
r = await client.get("/health", headers={"Authorization": ""})
assert r.status_code == 200
assert r.json() == {"status": "ok"}
+120
View File
@@ -0,0 +1,120 @@
import httpx
import pytest
import respx
from app.comfy_client import ComfyClient, extract_output_image
from app.errors import ComfyUIError
BASE = "http://comfy.test"
@pytest.fixture
def comfy():
client = ComfyClient(BASE)
yield client
async def _history_entry(filename="out_001.png", subfolder=""):
return {"outputs": {"60": {"images": [{"filename": filename, "subfolder": subfolder, "type": "output"}]}},
"status": {"status_str": "success", "completed": True}}
@respx.mock
async def test_submit_prompt_ok(comfy):
route = respx.post(f"{BASE}/prompt").mock(return_value=httpx.Response(200, json={"prompt_id": "p-1"}))
assert await comfy.submit_prompt({"1": {"class_type": "X", "inputs": {}}}) == "p-1"
body = route.calls.last.request.content.decode()
assert '"client_id"' in body
@respx.mock
async def test_submit_prompt_validation_error(comfy):
respx.post(f"{BASE}/prompt").mock(
return_value=httpx.Response(400, json={"error": {}, "node_errors": {"3": {"errors": [{"message": "bad link", "details": "'83:43'"}]}}})
)
with pytest.raises(ComfyUIError, match="83:43"):
await comfy.submit_prompt({})
@respx.mock
async def test_submit_prompt_server_error(comfy):
respx.post(f"{BASE}/prompt").mock(return_value=httpx.Response(500))
with pytest.raises(ComfyUIError, match="HTTP 500"):
await comfy.submit_prompt({})
@respx.mock
async def test_upload_image_ok_returns_name(comfy):
route = respx.post(f"{BASE}/upload/image").mock(return_value=httpx.Response(200, json={"name": "adapter_x.png", "type": "input"}))
name = await comfy.upload_image(b"pngbytes")
assert name == "adapter_x.png"
assert "multipart" in route.calls.last.request.headers["content-type"]
assert b"adapter_" in route.calls.last.request.content
@respx.mock
async def test_upload_image_error(comfy):
respx.post(f"{BASE}/upload/image").mock(return_value=httpx.Response(500))
with pytest.raises(ComfyUIError):
await comfy.upload_image(b"x")
@respx.mock
async def test_wait_for_result_returns_entry(comfy):
respx.get(f"{BASE}/history/p-1").mock(return_value=httpx.Response(200, json={"p-1": await _history_entry()}))
entry = await comfy.wait_for_result("p-1", timeout=5, interval=0)
assert entry["status"]["completed"] is True
@respx.mock
async def test_wait_for_result_execution_failed(comfy):
respx.get(f"{BASE}/history/p-1").mock(
return_value=httpx.Response(200, json={"p-1": {"outputs": {}, "status": {"status_str": "error", "completed": False}}})
)
with pytest.raises(ComfyUIError, match="execution failed"):
await comfy.wait_for_result("p-1", timeout=5, interval=0)
@respx.mock
async def test_wait_for_result_timeout(comfy):
respx.get(f"{BASE}/history/p-1").mock(return_value=httpx.Response(200, json={}))
with pytest.raises(ComfyUIError, match="Timed out"):
await comfy.wait_for_result("p-1", timeout=0.1, interval=0.01)
@respx.mock
async def test_fetch_view_ok(comfy):
respx.get(f"{BASE}/view").mock(return_value=httpx.Response(200, content=b"PNGDATA"))
assert await comfy.fetch_view("f.png") == b"PNGDATA"
@respx.mock
async def test_fetch_view_error(comfy):
respx.get(f"{BASE}/view").mock(return_value=httpx.Response(404))
with pytest.raises(ComfyUIError):
await comfy.fetch_view("missing.png")
@respx.mock
async def test_validate_graph_ok_uses_front_false(comfy):
route = respx.post(f"{BASE}/prompt").mock(return_value=httpx.Response(200, json={"prompt_id": "x"}))
await comfy.validate_graph({"1": {"class_type": "X", "inputs": {}}})
assert "front=false" in route.calls.last.request.url.query.decode() or "front" in str(route.calls.last.request.url)
@respx.mock
async def test_validate_graph_rejects(comfy):
respx.post(f"{BASE}/prompt").mock(return_value=httpx.Response(400, json={"node_errors": {"2": {"errors": [{"message": "missing", "details": ""}]}}}))
with pytest.raises(ComfyUIError, match="missing"):
await comfy.validate_graph({})
def test_extract_output_image_prefers_output_type():
entry = {"outputs": {"77": {"images": [{"filename": "prev.png", "type": "temp"}]},
"60": {"images": [{"filename": "real.png", "subfolder": "s", "type": "output"}]}}}
assert extract_output_image(entry) == ("real.png", "s")
def test_extract_output_image_missing():
with pytest.raises(ComfyUIError, match="no output images"):
extract_output_image({"outputs": {}})
+32
View File
@@ -0,0 +1,32 @@
from app.config import DEFAULT_MAPPING, Settings
def test_defaults():
s = Settings(_env_file=None)
assert s.comfy_base_url == "http://192.168.0.3:8188"
assert s.execution_timeout == 300.0
assert s.poll_interval == 1.5
assert s.mapping() == DEFAULT_MAPPING
def test_env_override(monkeypatch):
monkeypatch.setenv("COMFY_BASE_URL", "http://example:9000")
monkeypatch.setenv("ADAPTER_API_KEY", "sk-abc")
monkeypatch.setenv("EXECUTION_TIMEOUT", "42")
monkeypatch.setenv("POLL_INTERVAL", "0.5")
s = Settings(_env_file=None)
assert s.comfy_base_url == "http://example:9000"
assert s.adapter_api_key == "sk-abc"
assert s.execution_timeout == 42.0
assert s.poll_interval == 0.5
def test_mapping_file_override(tmp_path):
f = tmp_path / "mapping.json"
f.write_text('{"create": {"prompt": {"node": "1", "field": "t"}}}', encoding="utf-8")
s = Settings(_env_file=None, mapping_file=f)
m = s.mapping()
assert m["create"]["prompt"] == {"node": "1", "field": "t"}
# other sections/keys keep defaults
assert m["edit"] == DEFAULT_MAPPING["edit"]
assert m["create"]["seed"] == DEFAULT_MAPPING["create"]["seed"]
+107
View File
@@ -0,0 +1,107 @@
import base64
import httpx
import pytest
import respx
from tests.conftest import COMFY, png_bytes, png_size
EDITS = "http://adapter.test/v1/images/edits"
PROMPT_ID = "p-edit"
UPLOADED = "adapter_deadbeef_11223344.png"
HISTORY = {
PROMPT_ID: {
"outputs": {"60": {"images": [{"filename": "ed_00001_.png", "subfolder": "", "type": "output"}]}},
"status": {"status_str": "success", "completed": True},
}
}
def _mock_pipeline():
upload_route = respx.post(f"{COMFY}/upload/image").mock(return_value=httpx.Response(200, json={"name": UPLOADED, "type": "input"}))
prompt_route = respx.post(f"{COMFY}/prompt").mock(return_value=httpx.Response(200, json={"prompt_id": PROMPT_ID}))
respx.get(f"{COMFY}/history/{PROMPT_ID}").mock(return_value=httpx.Response(200, json=HISTORY))
respx.get(f"{COMFY}/view").mock(return_value=httpx.Response(200, content=png_bytes(1024, 1024)))
return upload_route, prompt_route
@respx.mock
async def test_edits_multipart_success(client: httpx.AsyncClient):
upload_route, prompt_route = _mock_pipeline()
files = {"image": ("in.png", png_bytes(64, 64), "image/png")}
r = await client.post(EDITS, files=files, data={"prompt": "make it night"})
assert r.status_code == 200
body = r.json()
img = base64.b64decode(body["data"][0]["b64_json"])
assert png_size(img) == (1024, 1024)
# uploaded file name landed in LoadImage node; prompt in positive TextEncode
upload_body = upload_route.calls.last.request.content
assert b"in.png" in upload_body or b"adapter_" in upload_body
sent = prompt_route.calls.last.request.content.decode()
assert UPLOADED in sent
assert "make it night" in sent
graph = prompt_route.calls.last.request.content # json bytes
assert b'"115:110"' in graph # negative node present with empty prompt
@respx.mock
async def test_edits_json_base64_success(client: httpx.AsyncClient):
_, prompt_route = _mock_pipeline()
b64 = base64.b64encode(png_bytes(64, 64)).decode()
r = await client.post(EDITS, json={"image": b64, "prompt": "add a hat", "model": "qwen-image-edit"})
assert r.status_code == 200
assert "add a hat" in prompt_route.calls.last.request.content.decode()
@respx.mock
async def test_edits_size_ignored(client: httpx.AsyncClient):
_, prompt_route = _mock_pipeline()
b64 = base64.b64encode(png_bytes(64, 64)).decode()
r = await client.post(EDITS, json={"image": b64, "prompt": "x", "size": "512x512"})
assert r.status_code == 200
sent = prompt_route.calls.last.request.content.decode()
assert "512" not in sent # size never reaches the workflow
async def test_edits_missing_image_400(client):
r = await client.post(EDITS, data={"prompt": "x"})
assert r.status_code == 400
async def test_edits_empty_image_400(client):
files = {"image": ("in.png", b"", "image/png")}
r = await client.post(EDITS, files=files, data={"prompt": "x"})
assert r.status_code == 400
async def test_edits_garbage_image_400(client):
files = {"image": ("in.png", b"not-an-image", "image/png")}
r = await client.post(EDITS, files=files, data={"prompt": "x"})
assert r.status_code == 400
async def test_edits_bad_base64_400(client):
r = await client.post(EDITS, json={"image": "!!!not base64!!!", "prompt": "x"})
assert r.status_code == 400
async def test_edits_empty_prompt_400(client):
b64 = base64.b64encode(png_bytes(8, 8)).decode()
r = await client.post(EDITS, json={"image": b64, "prompt": " "})
assert r.status_code == 400
async def test_edits_wrong_model_400(client):
b64 = base64.b64encode(png_bytes(8, 8)).decode()
r = await client.post(EDITS, json={"image": b64, "prompt": "x", "model": "z-image-turbo"})
assert r.status_code == 400
@respx.mock
async def test_edits_comfy_failure_502(client):
respx.post(f"{COMFY}/upload/image").mock(return_value=httpx.Response(200, json={"name": UPLOADED}))
respx.post(f"{COMFY}/prompt").mock(return_value=httpx.Response(400, json={"node_errors": {"x": {"errors": [{"message": "bad graph", "details": ""}]}}}))
b64 = base64.b64encode(png_bytes(8, 8)).decode()
r = await client.post(EDITS, json={"image": b64, "prompt": "x"})
assert r.status_code == 502
+29
View File
@@ -0,0 +1,29 @@
from app.errors import ComfyUIError, InvalidRequest, Unauthorized, error_body
from app.schemas import ImageGenerationRequest
import pytest
def test_error_body_canonical_shape():
body = error_body("boom", "server_error", "comfyui_timeout")
assert body == {"error": {"message": "boom", "type": "server_error", "param": None, "code": "comfyui_timeout"}}
def test_exception_status_mapping():
assert InvalidRequest("x").status == 400
assert Unauthorized().status == 401
assert ComfyUIError("x").status == 502
def test_generation_request_defaults():
r = ImageGenerationRequest(prompt="p")
assert r.size == "1024x1024"
assert r.n == 1
assert r.seed is None
assert r.model is None
@pytest.mark.parametrize("bad", ["", "abc", "1024", "1024x", "0x10", "10x0"])
def test_size_validation(bad):
with pytest.raises(ValueError):
ImageGenerationRequest(prompt="p", size=bad)
+140
View File
@@ -0,0 +1,140 @@
import base64
import json
import httpx
import pytest
import respx
from tests.conftest import COMFY, png_bytes, png_size
GEN = "http://adapter.test/v1/images/generations"
PROMPT_ID = "p-1"
def _history_json(width=64, height=64):
return {
PROMPT_ID: {
"outputs": {"60": {"images": [{"filename": "out_00001_.png", "subfolder": "", "type": "output"}]}},
"status": {"status_str": "success", "completed": True},
}
}
def _mock_pipeline(view_png: bytes | None = None, prompt_response: httpx.Response | None = None):
prompt_route = respx.post(f"{COMFY}/prompt").mock(
return_value=prompt_response or httpx.Response(200, json={"prompt_id": PROMPT_ID})
)
respx.get(f"{COMFY}/history/{PROMPT_ID}").mock(return_value=httpx.Response(200, json=_history_json()))
view_response = httpx.Response(200, content=view_png) if view_png else httpx.Response(500)
respx.get(f"{COMFY}/view").mock(return_value=view_response)
return prompt_route
@respx.mock
async def test_generation_success(client: httpx.AsyncClient):
png = png_bytes(1024, 1024)
prompt_route = _mock_pipeline(view_png=png)
r = await client.post(GEN, json={"prompt": "a red cube", "model": "z-image-turbo", "size": "1024x1024", "seed": 123})
assert r.status_code == 200
body = r.json()
assert set(body) == {"created", "data"} and set(body["data"][0]) == {"b64_json"}
img = base64.b64decode(body["data"][0]["b64_json"])
assert png_size(img) == (1024, 1024)
# injected values reached the workflow
sent = json.loads(prompt_route.calls.last.request.content.decode())["prompt"]
assert sent["83:27"]["inputs"]["text"] == "a red cube"
assert sent["83:13"]["inputs"]["width"] == 1024 and sent["83:13"]["inputs"]["height"] == 1024
assert sent["83:3"]["inputs"]["seed"] == 123
@respx.mock
async def test_generation_size_custom(client: httpx.AsyncClient):
prompt_route = _mock_pipeline(view_png=png_bytes(832, 1216))
r = await client.post(GEN, json={"prompt": "x", "size": "832x1216"})
assert r.status_code == 200
sent = json.loads(prompt_route.calls.last.request.content.decode())["prompt"]
assert (sent["83:13"]["inputs"]["width"], sent["83:13"]["inputs"]["height"]) == (832, 1216)
@respx.mock
async def test_generation_seed_random_per_request(client: httpx.AsyncClient):
prompt_route = _mock_pipeline(view_png=png_bytes())
await client.post(GEN, json={"prompt": "x"})
seed_a = json.loads(prompt_route.calls.last.request.content.decode())["prompt"]["83:3"]["inputs"]["seed"]
await client.post(GEN, json={"prompt": "x"})
seed_b = json.loads(prompt_route.calls.last.request.content.decode())["prompt"]["83:3"]["inputs"]["seed"]
assert seed_a != seed_b
async def _assert_400(r):
assert r.status_code == 400
err = r.json()["error"]
assert err["type"] == "invalid_request_error" and err["message"]
async def test_empty_prompt_400(client):
await _assert_400(await client.post(GEN, json={"prompt": " "}))
async def test_n_gt_1_400(client):
await _assert_400(await client.post(GEN, json={"prompt": "x", "n": 2}))
@pytest.mark.parametrize("bad", ["wide", "1024", "1024x", "0x512"])
async def test_bad_size_400(client, bad):
await _assert_400(await client.post(GEN, json={"prompt": "x", "size": bad}))
async def test_unknown_model_400(client):
await _assert_400(await client.post(GEN, json={"prompt": "x", "model": "dall-e-3"}))
async def test_bad_json_400(client):
await _assert_400(await client.post(GEN, content=b"{not json", headers={"Content-Type": "application/json"}))
@respx.mock
async def test_comfy_validation_failure_502(client):
_mock_pipeline(prompt_response=httpx.Response(400, json={"node_errors": {"83:27": {"errors": [{"message": "bad", "details": "'83:43'"}]}}}))
r = await client.post(GEN, json={"prompt": "x"})
assert r.status_code == 502
assert r.json()["error"]["code"] == "comfyui_validation_failed"
@respx.mock
async def test_comfy_execution_failure_502(client):
respx.post(f"{COMFY}/prompt").mock(return_value=httpx.Response(200, json={"prompt_id": PROMPT_ID}))
respx.get(f"{COMFY}/history/{PROMPT_ID}").mock(
return_value=httpx.Response(200, json={PROMPT_ID: {"outputs": {}, "status": {"status_str": "error", "completed": False}}})
)
r = await client.post(GEN, json={"prompt": "x"})
assert r.status_code == 502
assert r.json()["error"]["code"] == "comfyui_execution_failed"
@respx.mock
async def test_comfy_timeout_502(client):
respx.post(f"{COMFY}/prompt").mock(return_value=httpx.Response(200, json={"prompt_id": PROMPT_ID}))
respx.get(f"{COMFY}/history/{PROMPT_ID}").mock(return_value=httpx.Response(200, json={}))
# client fixture uses execution_timeout=5; shorten via a second app instance instead:
import contextlib
from app.main import create_app
from tests.conftest import settings as make_settings
app = create_app(make_settings(execution_timeout=0.05, poll_interval=0.0))
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(app.router.lifespan_context(app))
transport = httpx.ASGITransport(app=app)
ac = await stack.enter_async_context(httpx.AsyncClient(transport=transport, base_url="http://adapter.test"))
ac.headers["Authorization"] = client.headers["Authorization"]
r = await ac.post(GEN, json={"prompt": "x"})
assert r.status_code == 502
assert r.json()["error"]["code"] == "comfyui_timeout"
@respx.mock
async def test_comfy_unreachable_502(client):
respx.post(f"{COMFY}/prompt").mock(side_effect=httpx.ConnectError("boom"))
r = await client.post(GEN, json={"prompt": "x"})
assert r.status_code == 502
+73
View File
@@ -0,0 +1,73 @@
import pytest
from app.config import DEFAULT_MAPPING
from app.errors import InvalidRequest
from app.injector import build_workflow, new_seed, parse_size
from app.templates import load_all
from app.config import Settings
@pytest.fixture(scope="module")
def templates():
return load_all(Settings(_env_file=None))
def test_parse_size():
assert parse_size("1024x1024") == (1024, 1024)
assert parse_size("832X1216") == (832, 1216)
@pytest.mark.parametrize("bad", ["1024", "ax1024", "1024x", "0x100", "1024x-2", "wide"])
def test_parse_size_invalid(bad):
with pytest.raises(InvalidRequest):
parse_size(bad)
def test_seed_random_differs():
assert new_seed(None) != new_seed(None)
def test_seed_fixed_and_range():
assert new_seed(42) == 42
with pytest.raises(InvalidRequest):
new_seed(-1)
def test_create_injection(templates):
create, _ = templates
mapping = DEFAULT_MAPPING["create"]
graph = build_workflow(create, mapping, prompt="a cat", seed=77, size="832x1216")
assert graph["83:27"]["inputs"]["text"] == "a cat"
assert graph["83:13"]["inputs"]["width"] == 832
assert graph["83:13"]["inputs"]["height"] == 1216
assert graph["83:3"]["inputs"]["seed"] == 77
# template untouched
assert create["83:27"]["inputs"]["text"] == "\n"
assert create["83:13"]["inputs"]["width"] == 1280
def test_edit_injection(templates):
_, edit = templates
mapping = DEFAULT_MAPPING["edit"]
graph = build_workflow(edit, mapping, prompt="make it night", seed=5, image_name="adapter_x.png")
assert graph["115:111"]["inputs"]["prompt"] == "make it night"
assert graph["115:110"]["inputs"]["prompt"] == ""
assert graph["78"]["inputs"]["image"] == "adapter_x.png"
assert graph["115:3"]["inputs"]["seed"] == 5
def test_edit_seed_varies_per_call(templates):
_, edit = templates
mapping = DEFAULT_MAPPING["edit"]
seeds = set()
for _ in range(3):
g = build_workflow(edit, mapping, prompt="p", seed=new_seed(None))
seeds.add(g["115:3"]["inputs"]["seed"])
assert len(seeds) == 3
def test_unknown_mapping_node_raises(templates):
create, _ = templates
bad_mapping = {"prompt": {"node": "999", "field": "text"}}
with pytest.raises(InvalidRequest, match="999"):
build_workflow(create, bad_mapping, prompt="p", seed=1)
+70
View File
@@ -0,0 +1,70 @@
"""Live tests against the real ComfyUI server (192.168.0.3) via a running adapter.
Manual run:
1. start adapter: uvicorn app.main:app --port 8000
2. set env: ADAPTER_LIVE=1 ADAPTER_URL=http://127.0.0.1:8000 ADAPTER_KEY=<ADAPTER_API_KEY>
3. pytest -m live
"""
import base64
import os
import struct
import httpx
import pytest
pytestmark = pytest.mark.live
URL = os.environ.get("ADAPTER_URL", "http://127.0.0.1:8000")
KEY = os.environ.get("ADAPTER_KEY", "")
pytestmark = [
pytest.mark.live,
pytest.mark.skipif(not os.environ.get("ADAPTER_LIVE"), reason="set ADAPTER_LIVE=1 to run live tests"),
]
def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(base_url=URL, headers={"Authorization": f"Bearer {KEY}"}, timeout=600)
def _png_size(data: bytes) -> tuple[int, int]:
return struct.unpack(">II", data[16:24])
def _solid_png(w: int = 128, h: int = 96) -> bytes:
import zlib
def chunk(t, d):
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
raw = b"".join(b"\x00" + bytes((200, 40, 40)) * w for _ in range(h))
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")
async def test_generate_1024_with_seed():
async with _client() as c:
r = await c.post("/v1/images/generations", json={"prompt": "a red cube on white background", "size": "1024x1024", "seed": 20261004})
assert r.status_code == 200, r.text
img = base64.b64decode(r.json()["data"][0]["b64_json"])
assert img.startswith(b"\x89PNG")
assert _png_size(img) == (1024, 1024)
async def test_same_seed_same_image():
async with _client() as c:
payload = {"prompt": "a blue sphere", "size": "1024x1024", "seed": 777}
r1 = await c.post("/v1/images/generations", json=payload)
r2 = await c.post("/v1/images/generations", json=payload)
assert r1.json()["data"][0]["b64_json"] == r2.json()["data"][0]["b64_json"]
async def test_edit_output_is_about_1mp_with_input_aspect():
async with _client() as c:
files = {"image": ("in.png", _solid_png(128, 96), "image/png")}
r = await c.post("/v1/images/edits", files=files, data={"prompt": "make the background deep blue"})
assert r.status_code == 200, r.text
img = base64.b64decode(r.json()["data"][0]["b64_json"])
w, h = _png_size(img)
assert 0.9 <= w * h / 1_000_000 <= 1.15 # ~1 MP
assert abs(w / h - 128 / 96) < 0.05 # aspect preserved
+11
View File
@@ -0,0 +1,11 @@
import httpx
async def test_models_list(client: httpx.AsyncClient):
r = await client.get("/v1/models")
assert r.status_code == 200
body = r.json()
assert body["object"] == "list"
ids = [m["id"] for m in body["data"]]
assert ids == ["z-image-turbo", "qwen-image-edit"]
assert all(m["object"] == "model" for m in body["data"])
+57
View File
@@ -0,0 +1,57 @@
import pytest
from app.config import Settings
from app.templates import TemplateError, check_mapping, find_broken_links, load_all
def test_find_broken_links_detects_missing_node():
graph = {
"1": {"class_type": "X", "inputs": {"m": ["9", 0]}},
}
errors = find_broken_links(graph)
assert len(errors) == 1
assert "missing node '9'" in errors[0]
assert "node 1" in errors[0]
def test_find_broken_links_ok_on_valid_graph():
graph = {
"1": {"class_type": "X", "inputs": {"m": ["2", 0]}},
"2": {"class_type": "Y", "inputs": {}},
}
assert find_broken_links(graph) == []
def test_check_mapping_missing_node():
graph = {"1": {"class_type": "X", "inputs": {"text": ""}}}
mapping = {"create": {"prompt": {"node": "404", "field": "text"}}}
errors = check_mapping(graph, mapping["create"], "create")
assert any("not found" in e for e in errors)
def test_check_mapping_non_scalar_field():
graph = {"1": {"class_type": "X", "inputs": {"text": ["2", 0]}}}
mapping = {"create": {"prompt": {"node": "1", "field": "text"}}}
errors = check_mapping(graph, mapping["create"], "create")
assert any("no scalar input" in e for e in errors)
def test_shipped_templates_load_cleanly():
s = Settings(_env_file=None)
create, edit = load_all(s)
assert create["83:28"]["class_type"] == "UNETLoader"
assert edit["115:89"]["class_type"] == "LoraLoaderModelOnly"
def test_broken_template_blocks_startup(tmp_path):
bad = tmp_path / "bad.json"
bad.write_text('{"1": {"class_type": "X", "inputs": {"m": ["9", 0]}}}', encoding="utf-8")
good = tmp_path / "good.json"
good.write_text(
'{"1": {"class_type": "X", "inputs": {"m": ["2", 0]}}, "2": {"class_type": "Y", "inputs": {}}}',
encoding="utf-8",
)
s = Settings(_env_file=None, create_template_path=bad, edit_template_path=good)
with pytest.raises(TemplateError) as exc:
load_all(s)
assert "missing node '9'" in str(exc.value)
+78
View File
@@ -0,0 +1,78 @@
{
"60": {
"inputs": {
"filename_prefix": "z-image-turbo",
"images": ["83:8", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
},
"83:30": {
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "lumina2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load CLIP" }
},
"83:29": {
"inputs": { "vae_name": "ae.safetensors" },
"class_type": "VAELoader",
"_meta": { "title": "Load VAE" }
},
"83:13": {
"inputs": {
"width": 1280,
"height": 1280,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": { "title": "EmptySD3LatentImage" }
},
"83:33": {
"inputs": { "conditioning": ["83:27", 0] },
"class_type": "ConditioningZeroOut",
"_meta": { "title": "ConditioningZeroOut" }
},
"83:8": {
"inputs": {
"samples": ["83:3", 0],
"vae": ["83:29", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"83:3": {
"inputs": {
"seed": 578351132403975,
"steps": 9,
"cfg": 1,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": 1,
"model": ["83:28", 0],
"positive": ["83:27", 0],
"negative": ["83:33", 0],
"latent_image": ["83:13", 0]
},
"class_type": "KSampler",
"_meta": { "title": "KSampler" }
},
"83:27": {
"inputs": {
"text": "\n",
"clip": ["83:30", 0]
},
"class_type": "CLIPTextEncode",
"_meta": { "title": "CLIP Text Encode (Prompt)" }
},
"83:28": {
"inputs": {
"unet_name": "z_image_turbo_bf16.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": { "title": "Load Diffusion Model" }
}
}
+138
View File
@@ -0,0 +1,138 @@
{
"60": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": ["115:8", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
},
"78": {
"inputs": { "image": "" },
"class_type": "LoadImage",
"_meta": { "title": "Load Image" }
},
"115:75": {
"inputs": {
"strength": 1,
"model": ["115:66", 0]
},
"class_type": "CFGNorm",
"_meta": { "title": "CFGNorm" }
},
"115:39": {
"inputs": { "vae_name": "qwen_image_vae.safetensors" },
"class_type": "VAELoader",
"_meta": { "title": "Load VAE" }
},
"115:38": {
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load CLIP" }
},
"115:37": {
"inputs": {
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": { "title": "Load Diffusion Model" }
},
"115:110": {
"inputs": {
"prompt": "",
"clip": ["115:38", 0],
"vae": ["115:39", 0],
"image1": ["115:93", 0]
},
"class_type": "TextEncodeQwenImageEditPlus",
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
},
"115:66": {
"inputs": {
"shift": 3,
"model": ["115:89", 0]
},
"class_type": "ModelSamplingAuraFlow",
"_meta": { "title": "ModelSamplingAuraFlow" }
},
"115:111": {
"inputs": {
"prompt": "",
"clip": ["115:38", 0],
"vae": ["115:39", 0],
"image1": ["115:93", 0]
},
"class_type": "TextEncodeQwenImageEditPlus",
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
},
"115:112": {
"inputs": {
"width": 2560,
"height": 1440,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": { "title": "EmptySD3LatentImage" }
},
"115:88": {
"inputs": {
"pixels": ["115:93", 0],
"vae": ["115:39", 0]
},
"class_type": "VAEEncode",
"_meta": { "title": "VAE Encode" }
},
"115:93": {
"inputs": {
"upscale_method": "lanczos",
"megapixels": 1,
"resolution_steps": 1,
"image": ["78", 0]
},
"class_type": "ImageScaleToTotalPixels",
"_meta": { "title": "ImageScaleToTotalPixels" }
},
"115:3": {
"inputs": {
"seed": 1118877715456453,
"steps": 4,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": ["115:75", 0],
"positive": ["115:111", 0],
"negative": ["115:110", 0],
"latent_image": ["115:88", 0]
},
"class_type": "KSampler",
"_meta": { "title": "KSampler" }
},
"115:8": {
"inputs": {
"samples": ["115:3", 0],
"vae": ["115:39", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"115:116": {
"inputs": { "images": ["115:8", 0] },
"class_type": "PreviewImage",
"_meta": { "title": "PreviewImage" }
},
"115:89": {
"inputs": {
"lora_name": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
"strength_model": 1,
"model": ["115:37", 0]
},
"class_type": "LoraLoaderModelOnly",
"_meta": { "title": "LoraLoaderModelOnly" }
}
}