566 lines
33 KiB
Markdown
566 lines
33 KiB
Markdown
# comfy-adapter
|
||
|
||
**OpenAI-совместимый HTTP-адаптер для [ComfyUI](https://github.com/Comfy-Org/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.
|
||
|
||
---
|
||
|
||
## Быстрый старт
|
||
|
||
### Локально
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
docker build -t comfy-adapter .
|
||
docker run -p 8000:8000 --env-file .env comfy-adapter
|
||
```
|
||
|
||
### Проверка
|
||
|
||
```bash
|
||
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**
|
||
|
||
```json
|
||
{
|
||
"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`
|
||
|
||
Требует ключ. Ответ:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
{ "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
|
||
|
||
```bash
|
||
# Генерация
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```js
|
||
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-файлом:
|
||
|
||
```json
|
||
{
|
||
"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 с плейсхолдерами:
|
||
|
||
```json
|
||
"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.
|
||
|
||
---
|
||
|
||
## Тесты
|
||
|
||
```bash
|
||
pip install -e ".[dev]"
|
||
pytest # юнит- и интеграционные тесты на моках ComfyUI (respx) — сеть не нужна
|
||
```
|
||
|
||
Живые прогоны против реального ComfyUI (тратят GPU-время, реальные генерации):
|
||
|
||
```bash
|
||
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 МБ): чашка -> тёмно-синяя, фон -> ночная кухня, яблоко/композиция/пар/тени сохранены |
|
||
|
||
Инструкция в редактировании выполнена выборочно — изменено запрошенное, остальное
|
||
осталось идентичным входному кадру.
|