Files
comfy-adapter/README.md
T

33 KiB
Raw Blame History

comfy-adapter

OpenAI-совместимый HTTP-адаптер для ComfyUI.

Адаптер принимает стандартные запросы OpenAI Images API (/v1/images/generations, /v1/images/edits), подставляет параметры в заранее подготовленные workflow-шаблоны, выполняет их на сервере ComfyUI и возвращает готовое изображение в поле b64_json. Клиенту не нужно ничего знать о нодах, графах и формате workflow — ComfyUI становится обычным image-бэкендом с OpenAI-контрактом.

+----------------+   OpenAI Images API   +----------------+   ComfyUI HTTP API   +----------------+
|                |  -------------------->  |                |  ------------------>  |                |
|  Любой клиент  |   /v1/images/*          |  comfy-adapter  |    /prompt            |    ComfyUI     |
|  (OpenAI SDK,  |   Bearer <ключ>         |   (FastAPI)     |    /upload/image      |   (GPU-сервер) |
|   LangChain,   |   JSON / multipart      |                 |    /history/{id}      |                |
|   curl, ...)   |  <--------------------  |                 |  <------------------  |                |
+----------------+   b64_json (PNG)        +----------------+    /view               +----------------+

Модели

В API различаются два понятия «модель». API-модель — внешний алиас из реестра models.json, который лишь выбирает, какой workflow выполнить (см. «Реестр моделей»). Модель ComfyUI (диффузионный чекпойнт, LoRA, CLIP, VAE) неизменяема и зашита внутрь самого workflow-графа — она не подставляется из запроса. Каждая API-модель жёстко привязана к своему workflow-файлу с уже подобранной цепочкой моделей.

id модели в API Назначение Диффузионная модель (внутри workflow) Доп.
z-image-turbo генерация по текстовому промпту z_image_turbo_bf16.safetensors CLIP qwen_3_4b (lumina2), VAE ae
qwen-image-edit редактирование входного изображения qwen_image_edit_2509_fp8_e4m3fn.safetensors LoRA Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16 (4 шага), CLIP qwen_2.5_vl_7b_fp8_scaled (qwen_image), VAE qwen_image_vae

Проверено на ComfyUI 0.37.0 (Tesla V100 16GB, CUDA). Используются только штатные ноды и официальные HTTP-эндпоинты ComfyUI — кастомные ноды не требуются (TextEncodeQwenImageEditPlus входит в ядро, comfy_extras.nodes_qwen).


Возможности

  • Два эндпоинта в контракте OpenAI: генерация и редактирование изображений.
  • GET /v1/models — список доступных «моделей» (для автодополнения в клиентах).
  • Воспроизводимость: опциональный seed; один и тот же seed + промпт дают идентичный результат.
  • Толерантный парсинг OpenAI: неприменимые поля (quality, style, user) не ломают запрос.
  • Workflow как данные: реестр моделей models.json и workflow-файлы с плейсхолдерами вынесены из кода.
  • Защита от деградации графа: битые ссылки в шаблоне обнаруживаются локально и на сервере, адаптер не стартует с нерабочим workflow.
  • Статический API-ключ на всех эндпоинтах, кроме GET /health.
  • Ошибки в формате OpenAI (error.message/type/param/code) — клиенты корректно их разбирают.
  • Ответ всегда base64: адаптеру и клиенту не нужен сетевой доступ к ComfyUI.

Быстрый старт

Локально

cd comfy-adapter
python -m venv .venv
# Linux/macOS: .venv/bin/pip install -e .        Windows: .venv\Scripts\pip install -e .
cp .env.example .env       # заполните COMFY_BASE_URL и ADAPTER_API_KEY
uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level info

Docker

docker build -t comfy-adapter .
docker run -p 8000:8000 --env-file .env comfy-adapter

Проверка

curl -s http://localhost:8000/health
# {"status":"ok"}

Конфигурация по умолчанию указывает на http://192.168.0.3:8188 — поменяйте COMFY_BASE_URL, если ваш сервер другой. ComfyUI должен быть запущен с --listen, иначе адаптер до него не достучится.


Конфигурация

Все настройки — переменные окружения (или .env в рабочей директории).

Переменная По умолчанию Назначение
COMFY_BASE_URL http://192.168.0.3:8188 Базовый адрес сервера ComfyUI
ADAPTER_API_KEY пусто (обязательно) Ожидаемый Bearer-ключ клиентов. Пустое значение = все запросы получают 401 (fail-closed)
EXECUTION_TIMEOUT 300 Секунд ожидания результата от ComfyUI; превышение -> 502 comfyui_timeout
POLL_INTERVAL 1.5 Интервал опроса /history, сек
MODELS_FILE <репо>/models.json Реестр моделей: JSON id -> {workflow, owned_by} (обязателен, см. «Реестр моделей»)
VALIDATE_TEMPLATES_ON_SERVER true Прогонять собранные графы через POST /prompt?front=false при старте

Логирование стартовых проверок видно с uvicorn --log-level info:

INFO:comfy-adapter:workflow templates validated on ComfyUI server
INFO:comfy-adapter:comfy-adapter ready (server=http://192.168.0.3:8188)

API

Аутентификация

Все эндпоинты, кроме GET /health, требуют заголовок:

Authorization: Bearer <ADAPTER_API_KEY>

Отсутствие или несовпадение ключа -> 401 (code: "invalid_api_key"). Сравнение постоянного времени (secrets.compare_digest).

POST /v1/images/generations

Только application/json.

Параметр Тип По умолчанию Обязателен Описание
prompt string — да Текстовое описание. Пустая строка -> 400
model string | null null нет Id модели из реестра; пусто -> первая совместимая с генерацией (z-image-turbo). Неизвестная/несовместимая -> 400
n int | null 1 нет Поддерживается только 1, иначе 400
size string 1024x1024 нет Строго WxH, оба значения > 0. Попала в latent-ноду, то есть реально задаёт разрешение
seed int | null null нет 0..2^63-1. Без него — криптографически случайный (secrets.randbelow)
response_format string | null null нет Принимается; всегда возвращается b64_json

Ответ 200

{
  "created": 1791145663,
  "data": [{ "b64_json": "<base64 PNG>" }]
}

POST /v1/images/edits

Принимаются оба транспорта; набор полей одинаковый.

multipart/form-data (как в OpenAI):

Поле Тип Обязателен Описание
image файл да PNG или JPEG (проверка по сигнатуре байтов)
prompt string да Инструкция по редактированию
model string нет Id из реестра, совместимый с edits; пусто -> первая такая (qwen-image-edit). Иначе 400
seed int нет В форме передаётся строкой, парсится в int

application/json:

Поле Тип Обязателен Описание
image string (base64) да Допускается data-URL-префикс data:image/png;base64,...
prompt string да
model string нет Id из реестра, совместимый с edits; пусто -> первая такая (qwen-image-edit). Иначе 400
seed int нет
size string нет Принимается, но не влияет на результат — см. «Ограничения»

Ответ — тот же, что у генерации.

GET /v1/models

Требует ключ. Ответ:

{
  "object": "list",
  "data": [
    { "id": "z-image-turbo",    "object": "model", "owned_by": "comfy-adapter" },
    { "id": "qwen-image-edit",  "object": "model", "owned_by": "comfy-adapter" }
  ]
}

GET /health

Без ключа. {"status":"ok"} — для health-чеков оркестраторов и reverse proxy.

Ошибки

Канонический формат OpenAI:

{ "error": { "message": "...", "type": "...", "param": null, "code": "..." } }
HTTP type code Причина
400 invalid_request_error null Пустой prompt, n>1, неверный формат size, неизвестная model, битый JSON/base64, отсутствующий или недекодируемый image
401 invalid_request_error invalid_api_key Ключ отсутствует или не совпадает (в т.ч. при незаполненном ADAPTER_API_KEY)
502 server_error comfyui_validation_failed ComfyUI отверг граф; в message — разбор node_errors с id узлов
502 server_error comfyui_execution_failed Задание упало во время исполнения (status_str: error)
502 server_error comfyui_timeout Результат не появился за EXECUTION_TIMEOUT
502 server_error comfyui_no_output В истории нет изображений типа output
502 server_error comfyui_error ComfyUI недоступен или вернул неожиданный статус

Примеры

cURL

# Генерация
curl -s http://localhost:8000/v1/images/generations \
  -H "Authorization: Bearer $ADAPTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"a red apple on a wooden table next to steaming coffee","size":"1024x1024","seed":421337}' \
  | jq -r .data[0].b64_json | base64 -d > out.png

# Редактирование (multipart)
curl -s http://localhost:8000/v1/images/edits \
  -H "Authorization: Bearer $ADAPTER_API_KEY" \
  -F image=@out.png \
  -F prompt="change the cup to deep blue ceramic, background a dark kitchen at night, keep the apple the same" \
  | jq -r .data[0].b64_json | base64 -d > edited.png

# Список моделей
curl -s http://localhost:8000/v1/models -H "Authorization: Bearer $ADAPTER_API_KEY" | jq

Официальный Python SDK

import base64
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="sk-1234")

# Генерация. seed нет в подписи SDK -> передаём через extra_body
resp = client.images.generate(
    model="z-image-turbo",
    prompt="a red apple on a wooden table next to steaming coffee",
    size="1024x1024",
    n=1,
    extra_body={"seed": 421337},
)
open("out.png", "wb").write(base64.b64decode(resp.data[0].b64_json))

# Редактирование того же изображения
with open("out.png", "rb") as f:
    edited = client.images.edit(
        model="qwen-image-edit",
        image=("out.png", f.read(), "image/png"),
        prompt="change the cup to deep blue ceramic, keep the apple the same",
    )
open("edited.png", "wb").write(base64.b64decode(edited.data[0].b64_json))

Проверено с openai 3.24.0 — оба вызова проходят без кастомного транспорта.

JavaScript

import OpenAI from "openai";
import fs from "fs";

const client = new OpenAI({ baseURL: "http://localhost:8000/v1", apiKey: "sk-1234" });

const r = await client.images.generate({
  model: "z-image-turbo",
  prompt: "a red apple on a wooden table",
  size: "1024x1024",
  n: 1,
  seed: 421337, // поддерживается адаптером как обычное поле JSON-тела
});
fs.writeFileSync("out.png", Buffer.from(r.data[0].b64_json, "base64"));

Как это работает

Один и тот же цикл для обоих эндпоинтов:

request
   |
   v
[1] auth + валидация тела  --> 400 / 401
   |
   v
[2] deep-copy workflow выбранной модели, замещение плейсхолдеров {prompt}/{seed}/{width}/{height}/{image} значениями запроса
   |
   v
[3] (только edits) POST /upload/image  -> имя файла в input-каталоге ComfyUI
   |
   v
[4] POST /prompt (client_id = uuid4 на запрос)  --> prompt_id      (400 => comfyui_validation_failed)
   |
   v
[5] poll GET /history/{prompt_id} каждые POLL_INTERVAL, до EXECUTION_TIMEOUT
   |                                                    (status_str=error => comfyui_execution_failed)
   v
[6] GET /view?filename=...&type=output  -> байты PNG
   |
   v
[7] base64 -> data[0].b64_json

WebSocket /ws намеренно не используется: ComfyUI гарантированно пишет запись в историю после завершения задания, поэтому опрос проще, не держит долгоживущее соединение и устойчив к разрывам. Транспорт изолирован в app/comfy_client.py — переход на websocket не затронул бы остальной код.

Реестр моделей (models.json)

Список API-моделей задаётся декларативно, в файле models.json (путь — env MODELS_FILE, по умолчанию <репо>/models.json). Каждая запись связывает id модели с её workflow-файлом:

{
  "z-image-turbo":   { "workflow": "workflows/z_image_turbo.json",   "owned_by": "comfy-adapter" },
  "qwen-image-edit": { "workflow": "workflows/qwen_image_edit.json", "owned_by": "comfy-adapter" }
}
  • Добавить модель = добавить запись + файл workflow. Код править не нужно.
  • workflow — путь к JSON (относительный разрешается относительно папки models.json).
  • owned_by необязателен (по умолчанию comfy-adapter); используется в GET /v1/models.
  • Порядок записей значим: он задаёт приоритет выбора при model не задан.
  • Файл обязателен: отсутствующий или битый models.json -> явная ошибка конфигурации на старте (fail-fast), сервис не поднимется молча.

Выбор модели. Поле model выбирает запись реестра. Если model не задан или null — берётся первая совместимая с эндпоинтом модель. Неизвестный id или несовместимый с эндпоинтом -> 400. Совместимость вычисляется из набора токенов (см. ниже), отдельного поля «тип модели» в реестре нет.

Совместимость модели с эндпоинтом — вложенность множеств токенов, наборы принадлежат эндпоинту:

  модель допустима для эндпоинта  <=>  required(эндп) ⊆ токены(граф) ⊆ supplied(эндп)

  эндпоинт      supplied (что может подставить)          required (обязательно в графе)
  generations   prompt, negative, seed, width, height     prompt
  edits         prompt, negative, seed, image             prompt, image

Отсюда: генерация не примет модель с {image} (не может подставить), а редактирование не примет модель без {image} (картинка не должна остаться незамещённой).

Плейсхолдеры в workflow

Точка внедрения значений находится внутри workflow-графа — в значениях полей узлов пишутся токены вида {имя}. Никакой внешней карты узел↔поле нет; соответствие задаёт сам граф. Фиксированный словарь токенов принадлежит адаптеру:

Токен Тип Кто подставляет
{prompt} строка оба эндпоинта
{negative} строка всегда "" (запрос негатив не принимает)
{seed} число оба эндпоинта
{width} / {height} число только генерация (из size)
{image} строка только edits (имя загруженного файла)

Пример узла KSampler с плейсхолдерами:

"83:3": { "class_type": "KSampler", "inputs": { "seed": "{seed}", "...": 0, "model": ["83:28", 0] } }

Правила подстановки:

  • значение узла равно ровно {seed} -> подставляется число (типы сохраняются; иначе front=false не примет строку в int-виджете); токен внутри текста -> строковая вставка без искажения окружения;
  • нераспознанный токен (вне словаря) -> workflow отклоняется на старте;
  • незамещённый токен (эндпоинт не поставил значение) -> запрос отклоняется, ComfyUI не получает задание.

Проверка workflow

Двухуровневая, оба уровня на старте, для каждой модели реестра:

  1. Локально (app/templates.py): обход inputs в поиске ссылок [node_id, slot] на несуществующие узлы и проверка токенов (нет ли вне словаря). Найденное -> исключение со списком node X: input 'Y' links to missing node 'Z', адаптер не стартует.
  2. На сервере (VALIDATE_TEMPLATES_ON_SERVER=true): граф каждой модели собирается типизированными значениями-заглушками (dry-inject: seed=1, width=height=1024, prompt="x", negative="", image="dummy.png") и отправляется на POST /prompt?front=false — ComfyUI валидирует граф, не ставя задание в очередь и не тратя GPU. Заодно прогоняется сам инжектор. Ошибка логируется как диагностика старта и не блокирует запуск (согласно прежнему поведению).

Workflow-файлы и их починка

Оба workflow-файла были выгружены из OpenWebUI (там прямая интеграция с ComfyUI). В исходном виде они не проходили валидацию ComfyUI — в цепочках моделей отсутствовали узлы, на которые шли ссылки:

workflows/z_image_turbo.json                workflows/qwen_image_edit.json

83:28 UNETLoader                            115:37 UNETLoader
   |  на него никто не ссылается               |  на него никто не ссылается
   v                                          v
83:3 KSampler  --->  83:43 (НЕТ В ГРАФЕ)   115:66 ModelSamplingAuraFlow ---> 115:117 (НЕТ В ГРАФЕ)
83:27 CLIPTextEncode -> 83:43 (НЕТ)        115:89 LoraLoaderModelOnly (Lightning 4-step) - висит без потребителей

Ошибка сервера: prompt_outputs_failed_validation, KeyError: '83:43' / '115:117'.

Исправления, применённые в репозиторных файлах (минимальные, по одной ссылке на workflow; в те же файлы добавлены плейсхолдеры {prompt}/{seed}/{width}/{height}/{image}/{negative}):

Workflow Было Стало
workflows/z_image_turbo.json 83:3.inputs.model = ["83:43", 0], 83:27.inputs.clip = ["83:43", 1] ["83:28", 0] (UNETLoader), ["83:30", 0] (CLIPLoader)
workflows/qwen_image_edit.json 115:66.inputs.model = ["115:117", 0] ["115:89", 0] — LoRA-нода 115:89 подключена в цепочку (115:37 -> 115:89 -> 115:66 -> 115:75 -> KSampler)

Подключение LoRA во втором случае содержательно верно: в настройках OpenWebUI в поле «Модель» редактирования указан именно Lightning-LoRA на 4 шага, а steps: 4 в KSampler без него не имеет смысла.

Эффект: оба графа проходят front=false и реально выполняют полный цикл — проверено end-to-end на ComfyUI 0.37.0 (V100).

При смене workflow в OpenWebUI следует перезалить JSON сюда и сверить эти две цепочки; локальная проверка на старте поймает проблему автоматически.

Свойства workflow, которые стоит знать

  • Размер выхода в edit ≠ size. В графе за разрешение отвечает ImageScaleToTotalPixels (megapixels: 1), а latent из EmptySD3LatentImage перебивается VAEEncode входного изображения. Итог — ~1 Мп с аспектом входной картинки. Совместимо с OpenAI, который в edits размер тоже фактически игнорирует.
  • Negative prompt в create не используется. В графе стоит ConditioningZeroOut, поэтому Z-Image Turbo генерирует без негатива — это свойство модели (turbo/cfg=1), а не ограничение адаптера.
  • Параметры KSampler (steps, cfg, sampler_name, scheduler) и пути к моделям закреплены в шаблонах и из запроса не меняются. Правятся редактированием JSON.

Структура проекта

comfy-adapter/
├── app/
│   ├── main.py            FastAPI: эндпоинты, цикл execute, разбор multipart/JSON
│   ├── config.py          настройки из env + загрузка реестра моделей из models.json
│   ├── comfy_client.py    async-клиент ComfyUI: /prompt, /upload/image, /history, /view
│   ├── injector.py        deep-copy workflow + замещение плейсхолдеров, парсинг WxH, seed
│   ├── templates.py       загрузка реестра, проверка ссылок и токенов, выбор модели
│   ├── schemas.py         pydantic-модели OpenAI-контракта
│   ├── errors.py          иерархия исключений + обработчики в формат OpenAI
│   └── auth.py            проверка статического Bearer-ключа
├── tests/
│   ├── conftest.py          фикстура: приложение с lifespan и ComfyUI, указывающим на мок
│   ├── test_generations.py  генерация: успех, инъекция значений, seed, все классы 400/502
│   ├── test_edits.py        edits: multipart и base64, игнорирование size, ошибки входа
│   ├── test_comfy_client.py каждый метод клиента на respx-моках (успех/HTTP-ошибка/таймаут)
│   ├── test_injector.py     плейсхолдеры, deep-copy без мутации, seed, парсинг size
│   ├── test_templates.py    битые ссылки, токены, совместимость и выбор модели, ошибки реестра
│   ├── test_config.py       дефолты, переопределение из env, загрузка и fail-fast реестра
│   ├── test_auth.py         ключ: отсутствует / неверный / верный; health без ключа
│   ├── test_errors.py       канонический вид error-body, соответствие статусов
│   ├── test_models.py       формат /v1/models
│   ├── test_registry_integration.py новая модель только правкой models.json (без кода)
│   └── test_live.py         живые прогоны против реального ComfyUI (маркер live)
├── models.json            реестр API-моделей: id -> {workflow, owned_by}
├── workflows/             workflow-файлы с плейсхолдерами (z_image_turbo.json, qwen_image_edit.json)
├── Dockerfile             python:3.12-slim + uvicorn
├── pyproject.toml
└── .env.example

Технологии: Python 3.12+, FastAPI, uvicorn, httpx (async), pydantic v2 + pydantic-settings. Тесты: pytest, pytest-asyncio, respx.


Тесты

pip install -e ".[dev]"
pytest              # юнит- и интеграционные тесты на моках ComfyUI (respx) — сеть не нужна

Живые прогоны против реального ComfyUI (тратят GPU-время, реальные генерации):

uvicorn app.main:app --port 8000 &
export ADAPTER_LIVE=1 ADAPTER_URL=http://127.0.0.1:8000 ADAPTER_KEY=<ваш ключ>
pytest -m live

Проверяют: генерацию 1024x1024 с декодированием и верным размером, детерминизм по seed (повтор даёт идентичный base64), редактирование с выходом ~1 Мп и сохранением аспекта входа.


Ограничения

  • n > 1 не поддерживается -> 400. Нужен — правьте batch_size в workflow или запускайте несколько запросов.
  • size в /v1/images/edits не влияет на выход (см. «Свойства workflow»): edit-граф не содержит {width}/{height}, эндпоинт edits их не поставляет.
  • negative_prompt из запроса не принимается. Поля в схеме запроса нет — токен {negative}, если он есть в workflow, всегда резолвится в пустую строку "".
  • model не задан -> берётся первая совместимая с эндпоинтом модель реестра (порядок записей в models.json задаёт приоритет). Явный default-флаг пока не реализован.
  • Размер в генерации не квантуется. Любое корректное WxH уходит в latent напрямую; нестандартные значения могут дать артефакты или ошибку уже на стороне ComfyUI (прилетит как 502, а не 400).
  • response_format: "url" не реализован — всегда b64_json.
  • Синхронная модель. HTTP-соединение держится до конца генерации; отдельной очереди заданий нет, конкурентные запросы сериализуются очередью ComfyUI.
  • Один сервер ComfyUI из COMFY_BASE_URL, без пула и балансировки.
  • Загружаемые изображения (edits) остаются в input-каталоге ComfyUI. Имена уникальны (adapter_<uuid>_<rand>.png), коллизий нет, автоматической очистки нет.
  • Неизвестные поля запроса игнорируются (стандартное поведение pydantic), а не отклоняются.

Дорожная карта (осознанно отложено)

negative_prompt в запросе -> quantize размера под кратные значения -> несколько ComfyUI-серверов и очереди -> response_format: "url" для публикации за пределами LAN -> websocket вместо опроса, если понадобится меньшая задержка.


Эксплуатация

  • Reverse proxy. Ответ синхронный и может занимать десятки секунд — поднимите proxy_read_timeout (nginx) или эквивалент выше, чем EXECUTION_TIMEOUT, иначе прокси оборвёт соединение раньше адаптера.
  • VRAM. На 16GB V100 модели догружаются и выгружаются по очереди; при чередовании генерации и редактирования первые кадры медленнее из-за загрузки весов. Это поведение ComfyUI, адаптер на него не влияет.
  • Безопасность. Ключ задаётся только через окружение, в репозиторий не попадает (.env в .gitignore). При пустом ADAPTER_API_KEY сервис закрыт по умолчанию.
  • Развёртывание рядом с OpenWebUI. Адаптер ничего не мигрирует: нативная интеграция OpenWebUI с ComfyUI продолжает работать напрямую, адаптер — независимый параллельный путь к тем же workflow.
  • Мониторинг. GET /health без ключа; стартовые строки workflow templates validated... / comfy-adapter ready подтверждают работоспособность графов.

Проверенное поведение

Не только тесты — контракт прогонялся живьём через официальный OpenAI SDK:

Шаг Вызов Результат
Генерация client.images.generate(model="z-image-turbo", seed=421337, size="1024x1024") 200, PNG 1024x1024 (~1.0 МБ), кадр точно по промпту (яблоко, дымящий кофе, деревянный стол, мягкий свет)
Редактирование client.images.edit(model="qwen-image-edit", image=out.png, prompt=...) 200, PNG 1024x1024 (~0.9 МБ): чашка -> тёмно-синяя, фон -> ночная кухня, яблоко/композиция/пар/тени сохранены

Инструкция в редактировании выполнена выборочно — изменено запрошенное, остальное осталось идентичным входному кадру.