Доработка управления моделями

This commit is contained in:
Shaman
2026-10-05 02:19:46 +03:00
parent f5ec608fc2
commit 0c5c0b423b
16 changed files with 629 additions and 252 deletions
+112 -54
View File
@@ -20,7 +20,13 @@
**Модели**
| id модели в API | Назначение | Диффузионная модель | Доп. |
В 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` |
@@ -37,7 +43,7 @@
- **`GET /v1/models`** — список доступных «моделей» (для автодополнения в клиентах).
- **Воспроизводимость**: опциональный `seed`; один и тот же `seed` + промпт дают идентичный результат.
- **Толерантный парсинг OpenAI**: неприменимые поля (`quality`, `style`, `user`) не ломают запрос.
- **Workflow как данные**: шаблоны JSON и карта маппинга параметров на узлы вынесены из кода.
- **Workflow как данные**: реестр моделей `models.json` и workflow-файлы с плейсхолдерами вынесены из кода.
- **Защита от деградации графа**: битые ссылки в шаблоне обнаруживаются локально и на сервере, адаптер не стартует с нерабочим workflow.
- **Статический API-ключ** на всех эндпоинтах, кроме `GET /health`.
- **Ошибки в формате OpenAI** (`error.message/type/param/code`) — клиенты корректно их разбирают.
@@ -87,10 +93,8 @@ curl -s http://localhost:8000/health
| `ADAPTER_API_KEY` | пусто (**обязательно**) | Ожидаемый Bearer-ключ клиентов. Пустое значение = все запросы получают 401 (fail-closed) |
| `EXECUTION_TIMEOUT` | `300` | Секунд ожидания результата от ComfyUI; превышение -> 502 `comfyui_timeout` |
| `POLL_INTERVAL` | `1.5` | Интервал опроса `/history`, сек |
| `CREATE_TEMPLATE_PATH` | `<репо>/workflow_create.json` | Путь к шаблону генерации |
| `EDIT_TEMPLATE_PATH` | `<репо>/workflow_edit.json` | Путь к шаблону редактирования |
| `MAPPING_FILE` | не задан | JSON с переопределением карты маппинга (см. ниже) |
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Прогонять графы через `POST /prompt?front=false` при старте |
| `MODELS_FILE` | `<репо>/models.json` | Реестр моделей: JSON `id -> {workflow, owned_by}` (обязателен, см. «Реестр моделей») |
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Прогонять собранные графы через `POST /prompt?front=false` при старте |
Логирование стартовых проверок видно с `uvicorn --log-level info`:
@@ -121,7 +125,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
| Параметр | Тип | По умолчанию | Обязателен | Описание |
|---|---|---|---|---|
| `prompt` | string | — | **да** | Текстовое описание. Пустая строка -> 400 |
| `model` | string \| null | `null` | нет | Если задан — только `z-image-turbo`, иначе 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`) |
@@ -146,7 +150,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
|---|---|---|---|
| `image` | файл | **да** | PNG или JPEG (проверка по сигнатуре байтов) |
| `prompt` | string | **да** | Инструкция по редактированию |
| `model` | string | нет | Только `qwen-image-edit` |
| `model` | string | нет | Id из реестра, совместимый с edits; пусто -> первая такая (`qwen-image-edit`). Иначе 400 |
| `seed` | int | нет | В форме передаётся строкой, парсится в int |
**application/json:**
@@ -155,7 +159,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
|---|---|---|---|
| `image` | string (base64) | **да** | Допускается data-URL-префикс `data:image/png;base64,...` |
| `prompt` | string | **да** | |
| `model` | string | нет | Только `qwen-image-edit` |
| `model` | string | нет | Id из реестра, совместимый с edits; пусто -> первая такая (`qwen-image-edit`). Иначе 400 |
| `seed` | int | нет | |
| `size` | string | нет | **Принимается, но не влияет на результат** — см. «Ограничения» |
@@ -283,7 +287,7 @@ request
[1] auth + валидация тела --> 400 / 401
|
v
[2] deep-copy шаблона workflow, подстановка prompt/size/seed/image по карте маппинга
[2] deep-copy workflow выбранной модели, замещение плейсхолдеров {prompt}/{seed}/{width}/{height}/{image} значениями запроса
|
v
[3] (только edits) POST /upload/image -> имя файла в input-каталоге ComfyUI
@@ -306,49 +310,98 @@ WebSocket `/ws` намеренно не используется: ComfyUI гар
соединение и устойчив к разрывам. Транспорт изолирован в `app/comfy_client.py` —
переход на websocket не затронул бы остальной код.
### Карта маппинга
### Реестр моделей (models.json)
Соответствие параметра запроса узлу workflow. Правится **без изменения кода** через
`MAPPING_FILE` (JSON с секциями `create` и `edit`; заданные ключи перезаписывают
дефолт, остальные сохраняются).
| Параметр | `workflow_create.json` | `workflow_edit.json` |
|---|---|---|
| `prompt` | `83:27` -> `inputs.text` (CLIPTextEncode) | `115:111` -> `inputs.prompt` (TextEncodeQwenImageEditPlus) |
| negative | — (в графе `ConditioningZeroOut`, константа) | `115:110` -> `inputs.prompt` (всегда `""`) |
| `size` | `83:13` -> `inputs.width` / `inputs.height` (EmptySD3LatentImage) | — (см. «Ограничения») |
| `seed` | `83:3` -> `inputs.seed` (KSampler) | `115:3` -> `inputs.seed` (KSampler) |
| `image` | — | `78` -> `inputs.image` (LoadImage) |
Пример переопределения:
Список API-моделей задаётся **декларативно**, в файле `models.json` (путь — env
`MODELS_FILE`, по умолчанию `<репо>/models.json`). Каждая запись связывает id модели с её
workflow-файлом:
```json
{ "create": { "prompt": { "node": "99:1", "field": "text" } } }
{
"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" }
}
```
При старте каждый id из карты проверяется на существование в шаблоне и на то, что
поле скалярное, — рассинхрон карты и шаблона падает на старт, а не на первый запрос.
- **Добавить модель = добавить запись + файл workflow. Код править не нужно.**
- `workflow` — путь к JSON (относительный разрешается относительно папки `models.json`).
- `owned_by` необязателен (по умолчанию `comfy-adapter`); используется в `GET /v1/models`.
- **Порядок записей значим**: он задаёт приоритет выбора при `model` не задан.
- Файл обязателен: отсутствующий или битый `models.json` -> явная ошибка конфигурации на
старте (fail-fast), сервис не поднимется молча.
### Проверка шаблонов
**Выбор модели.** Поле `model` выбирает запись реестра. Если `model` не задан или `null` —
берётся **первая совместимая с эндпоинтом** модель. Неизвестный id или несовместимый с
эндпоинтом -> `400`. Совместимость вычисляется из набора токенов (см. ниже), отдельного
поля «тип модели» в реестре нет.
Двухуровневая, оба уровня на старте:
**Совместимость модели с эндпоинтом** — вложенность множеств токенов, наборы принадлежат
эндпоинту:
1. **Локально** (`app/templates.py`): обход всех `inputs` и поиск ссылок
`[node_id, slot]` на несуществующие узлы. Найденное -> исключение со списком
`node X: input 'Y' links to missing node 'Z'`, адаптер не стартует.
2. **На сервере** (`VALIDATE_TEMPLATES_ON_SERVER=true`): `POST /prompt?front=false` —
ComfyUI валидирует граф, **не ставя задание в очередь** и не тратя GPU.
```
модель допустима для эндпоинта <=> 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). В
Оба workflow-файла были выгружены из OpenWebUI (там прямая интеграция с ComfyUI). В
**исходном виде они не проходили валидацию ComfyUI** — в цепочках моделей
отсутствовали узлы, на которые шли ссылки:
```
workflow_create.json workflow_edit.json
workflows/z_image_turbo.json workflows/qwen_image_edit.json
83:28 UNETLoader 115:37 UNETLoader
| на него никто не ссылается | на него никто не ссылается
@@ -359,12 +412,13 @@ workflow_create.json workflow_edit.json
Ошибка сервера: `prompt_outputs_failed_validation`, `KeyError: '83:43'` / `'115:117'`.
Исправления, применённые в репозиторных файлах (минимальные, по одной ссылке на шаблон):
Исправления, применённые в репозиторных файлах (минимальные, по одной ссылке на workflow;
в те же файлы добавлены плейсхолдеры `{prompt}`/`{seed}`/`{width}`/`{height}`/`{image}`/`{negative}`):
| Шаблон | Было | Стало |
| Workflow | Было | Стало |
|---|---|---|
| `workflow_create.json` | `83:3.inputs.model = ["83:43", 0]`, `83:27.inputs.clip = ["83:43", 1]` | `["83:28", 0]` (UNETLoader), `["83:30", 0]` (CLIPLoader) |
| `workflow_edit.json` | `115:66.inputs.model = ["115:117", 0]` | `["115:89", 0]` — LoRA-нода `115:89` подключена в цепочку (`115:37 -> 115:89 -> 115:66 -> 115:75 -> KSampler`) |
| `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` в
@@ -376,7 +430,7 @@ KSampler без него не имеет смысла.
При смене workflow в OpenWebUI следует перезалить JSON сюда и сверить эти две
цепочки; локальная проверка на старте поймает проблему автоматически.
### Свойства шаблонов, которые стоит знать
### Свойства workflow, которые стоит знать
- **Размер выхода в edit ≠ `size`.** В графе за разрешение отвечает
`ImageScaleToTotalPixels` (`megapixels: 1`), а latent из `EmptySD3LatentImage`
@@ -396,10 +450,10 @@ KSampler без него не имеет смысла.
comfy-adapter/
├── app/
│ ├── main.py FastAPI: эндпоинты, цикл execute, разбор multipart/JSON
│ ├── config.py настройки из env + дефолтная карта маппинга
│ ├── config.py настройки из env + загрузка реестра моделей из models.json
│ ├── comfy_client.py async-клиент ComfyUI: /prompt, /upload/image, /history, /view
│ ├── injector.py deep-copy шаблона + подстановка значений, парсинг WxH, seed
│ ├── templates.py загрузка шаблонов, проверка битых ссылок и соответствия карте
│ ├── injector.py deep-copy workflow + замещение плейсхолдеров, парсинг WxH, seed
│ ├── templates.py загрузка реестра, проверка ссылок и токенов, выбор модели
│ ├── schemas.py pydantic-модели OpenAI-контракта
│ ├── errors.py иерархия исключений + обработчики в формат OpenAI
│ └── auth.py проверка статического Bearer-ключа
@@ -408,15 +462,16 @@ comfy-adapter/
│ ├── 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, слияние MAPPING_FILE
│ ├── 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)
├── workflow_create.json исправленный шаблон генерации
├── workflow_edit.json исправленный шаблон редактирования
├── 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
@@ -450,11 +505,14 @@ seed (повтор даёт идентичный base64), редактирова
## Ограничения
- **`n > 1` не поддерживается** -> 400. Нужен — правьте `batch_size` в шаблоне или
- **`n > 1` не поддерживается** -> 400. Нужен — правьте `batch_size` в workflow или
запускайте несколько запросов.
- **`size` в `/v1/images/edits` не влияет на выход** (см. «Свойства шаблонов»).
- **`negative_prompt` из запроса не принимается.** Узел в шаблоне есть, но поля в
схеме запроса нет — адаптер всегда пишет пустую строку.
- **`size` в `/v1/images/edits` не влияет на выход** (см. «Свойства workflow»): edit-граф не
содержит `{width}`/`{height}`, эндпоинт edits их не поставляет.
- **`negative_prompt` из запроса не принимается.** Поля в схеме запроса нет — токен
`{negative}`, если он есть в workflow, всегда резолвится в пустую строку `""`.
- **`model` не задан -> берётся первая совместимая с эндпоинтом модель** реестра (порядок
записей в `models.json` задаёт приоритет). Явный `default`-флаг пока не реализован.
- **Размер в генерации не квантуется.** Любое корректное `WxH` уходит в latent
напрямую; нестандартные значения могут дать артефакты или ошибку уже на стороне
ComfyUI (прилетит как 502, а не 400).