33 KiB
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
Двухуровневая, оба уровня на старте, для каждой модели реестра:
- Локально (
app/templates.py): обходinputsв поиске ссылок[node_id, slot]на несуществующие узлы и проверка токенов (нет ли вне словаря). Найденное -> исключение со спискомnode X: input 'Y' links to missing node 'Z', адаптер не стартует. - На сервере (
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 МБ): чашка -> тёмно-синяя, фон -> ночная кухня, яблоко/композиция/пар/тени сохранены |
Инструкция в редактировании выполнена выборочно — изменено запрошенное, остальное осталось идентичным входному кадру.