Доработка управления моделями
This commit is contained in:
+3
-2
@@ -6,5 +6,6 @@ ADAPTER_API_KEY=sk-1234
|
|||||||
EXECUTION_TIMEOUT=300
|
EXECUTION_TIMEOUT=300
|
||||||
# Poll interval for /history, seconds
|
# Poll interval for /history, seconds
|
||||||
POLL_INTERVAL=1.5
|
POLL_INTERVAL=1.5
|
||||||
# Optional: JSON file overriding node mappings (see app/config.py defaults)
|
# Model registry: JSON mapping an API model id to its workflow file (required).
|
||||||
# MAPPING_FILE=mapping.json
|
# Defaults to models.json next to the app; override the path here if you move it.
|
||||||
|
MODELS_FILE=models.json
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
|||||||
|
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY workflow_create.json workflow_edit.json ./
|
COPY models.json ./
|
||||||
|
COPY workflows ./workflows
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -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` |
|
| `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` |
|
| `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`** — список доступных «моделей» (для автодополнения в клиентах).
|
- **`GET /v1/models`** — список доступных «моделей» (для автодополнения в клиентах).
|
||||||
- **Воспроизводимость**: опциональный `seed`; один и тот же `seed` + промпт дают идентичный результат.
|
- **Воспроизводимость**: опциональный `seed`; один и тот же `seed` + промпт дают идентичный результат.
|
||||||
- **Толерантный парсинг OpenAI**: неприменимые поля (`quality`, `style`, `user`) не ломают запрос.
|
- **Толерантный парсинг OpenAI**: неприменимые поля (`quality`, `style`, `user`) не ломают запрос.
|
||||||
- **Workflow как данные**: шаблоны JSON и карта маппинга параметров на узлы вынесены из кода.
|
- **Workflow как данные**: реестр моделей `models.json` и workflow-файлы с плейсхолдерами вынесены из кода.
|
||||||
- **Защита от деградации графа**: битые ссылки в шаблоне обнаруживаются локально и на сервере, адаптер не стартует с нерабочим workflow.
|
- **Защита от деградации графа**: битые ссылки в шаблоне обнаруживаются локально и на сервере, адаптер не стартует с нерабочим workflow.
|
||||||
- **Статический API-ключ** на всех эндпоинтах, кроме `GET /health`.
|
- **Статический API-ключ** на всех эндпоинтах, кроме `GET /health`.
|
||||||
- **Ошибки в формате OpenAI** (`error.message/type/param/code`) — клиенты корректно их разбирают.
|
- **Ошибки в формате OpenAI** (`error.message/type/param/code`) — клиенты корректно их разбирают.
|
||||||
@@ -87,10 +93,8 @@ curl -s http://localhost:8000/health
|
|||||||
| `ADAPTER_API_KEY` | пусто (**обязательно**) | Ожидаемый Bearer-ключ клиентов. Пустое значение = все запросы получают 401 (fail-closed) |
|
| `ADAPTER_API_KEY` | пусто (**обязательно**) | Ожидаемый Bearer-ключ клиентов. Пустое значение = все запросы получают 401 (fail-closed) |
|
||||||
| `EXECUTION_TIMEOUT` | `300` | Секунд ожидания результата от ComfyUI; превышение -> 502 `comfyui_timeout` |
|
| `EXECUTION_TIMEOUT` | `300` | Секунд ожидания результата от ComfyUI; превышение -> 502 `comfyui_timeout` |
|
||||||
| `POLL_INTERVAL` | `1.5` | Интервал опроса `/history`, сек |
|
| `POLL_INTERVAL` | `1.5` | Интервал опроса `/history`, сек |
|
||||||
| `CREATE_TEMPLATE_PATH` | `<репо>/workflow_create.json` | Путь к шаблону генерации |
|
| `MODELS_FILE` | `<репо>/models.json` | Реестр моделей: JSON `id -> {workflow, owned_by}` (обязателен, см. «Реестр моделей») |
|
||||||
| `EDIT_TEMPLATE_PATH` | `<репо>/workflow_edit.json` | Путь к шаблону редактирования |
|
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Прогонять собранные графы через `POST /prompt?front=false` при старте |
|
||||||
| `MAPPING_FILE` | не задан | JSON с переопределением карты маппинга (см. ниже) |
|
|
||||||
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Прогонять графы через `POST /prompt?front=false` при старте |
|
|
||||||
|
|
||||||
Логирование стартовых проверок видно с `uvicorn --log-level info`:
|
Логирование стартовых проверок видно с `uvicorn --log-level info`:
|
||||||
|
|
||||||
@@ -121,7 +125,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
|
|||||||
| Параметр | Тип | По умолчанию | Обязателен | Описание |
|
| Параметр | Тип | По умолчанию | Обязателен | Описание |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `prompt` | string | — | **да** | Текстовое описание. Пустая строка -> 400 |
|
| `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 |
|
| `n` | int \| null | `1` | нет | Поддерживается только `1`, иначе 400 |
|
||||||
| `size` | string | `1024x1024` | нет | Строго `WxH`, оба значения > 0. Попала в latent-ноду, то есть реально задаёт разрешение |
|
| `size` | string | `1024x1024` | нет | Строго `WxH`, оба значения > 0. Попала в latent-ноду, то есть реально задаёт разрешение |
|
||||||
| `seed` | int \| null | `null` | нет | `0..2^63-1`. Без него — криптографически случайный (`secrets.randbelow`) |
|
| `seed` | int \| null | `null` | нет | `0..2^63-1`. Без него — криптографически случайный (`secrets.randbelow`) |
|
||||||
@@ -146,7 +150,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `image` | файл | **да** | PNG или JPEG (проверка по сигнатуре байтов) |
|
| `image` | файл | **да** | PNG или JPEG (проверка по сигнатуре байтов) |
|
||||||
| `prompt` | string | **да** | Инструкция по редактированию |
|
| `prompt` | string | **да** | Инструкция по редактированию |
|
||||||
| `model` | string | нет | Только `qwen-image-edit` |
|
| `model` | string | нет | Id из реестра, совместимый с edits; пусто -> первая такая (`qwen-image-edit`). Иначе 400 |
|
||||||
| `seed` | int | нет | В форме передаётся строкой, парсится в int |
|
| `seed` | int | нет | В форме передаётся строкой, парсится в int |
|
||||||
|
|
||||||
**application/json:**
|
**application/json:**
|
||||||
@@ -155,7 +159,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `image` | string (base64) | **да** | Допускается data-URL-префикс `data:image/png;base64,...` |
|
| `image` | string (base64) | **да** | Допускается data-URL-префикс `data:image/png;base64,...` |
|
||||||
| `prompt` | string | **да** | |
|
| `prompt` | string | **да** | |
|
||||||
| `model` | string | нет | Только `qwen-image-edit` |
|
| `model` | string | нет | Id из реестра, совместимый с edits; пусто -> первая такая (`qwen-image-edit`). Иначе 400 |
|
||||||
| `seed` | int | нет | |
|
| `seed` | int | нет | |
|
||||||
| `size` | string | нет | **Принимается, но не влияет на результат** — см. «Ограничения» |
|
| `size` | string | нет | **Принимается, но не влияет на результат** — см. «Ограничения» |
|
||||||
|
|
||||||
@@ -283,7 +287,7 @@ request
|
|||||||
[1] auth + валидация тела --> 400 / 401
|
[1] auth + валидация тела --> 400 / 401
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
[2] deep-copy шаблона workflow, подстановка prompt/size/seed/image по карте маппинга
|
[2] deep-copy workflow выбранной модели, замещение плейсхолдеров {prompt}/{seed}/{width}/{height}/{image} значениями запроса
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
[3] (только edits) POST /upload/image -> имя файла в input-каталоге ComfyUI
|
[3] (только edits) POST /upload/image -> имя файла в input-каталоге ComfyUI
|
||||||
@@ -306,49 +310,98 @@ WebSocket `/ws` намеренно не используется: ComfyUI гар
|
|||||||
соединение и устойчив к разрывам. Транспорт изолирован в `app/comfy_client.py` —
|
соединение и устойчив к разрывам. Транспорт изолирован в `app/comfy_client.py` —
|
||||||
переход на websocket не затронул бы остальной код.
|
переход на websocket не затронул бы остальной код.
|
||||||
|
|
||||||
### Карта маппинга
|
### Реестр моделей (models.json)
|
||||||
|
|
||||||
Соответствие параметра запроса узлу workflow. Правится **без изменения кода** через
|
Список API-моделей задаётся **декларативно**, в файле `models.json` (путь — env
|
||||||
`MAPPING_FILE` (JSON с секциями `create` и `edit`; заданные ключи перезаписывают
|
`MODELS_FILE`, по умолчанию `<репо>/models.json`). Каждая запись связывает id модели с её
|
||||||
дефолт, остальные сохраняются).
|
workflow-файлом:
|
||||||
|
|
||||||
| Параметр | `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) |
|
|
||||||
|
|
||||||
Пример переопределения:
|
|
||||||
|
|
||||||
```json
|
```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]` на несуществующие узлы. Найденное -> исключение со списком
|
модель допустима для эндпоинта <=> required(эндп) ⊆ токены(граф) ⊆ supplied(эндп)
|
||||||
`node X: input 'Y' links to missing node 'Z'`, адаптер не стартует.
|
|
||||||
2. **На сервере** (`VALIDATE_TEMPLATES_ON_SERVER=true`): `POST /prompt?front=false` —
|
эндпоинт supplied (что может подставить) required (обязательно в графе)
|
||||||
ComfyUI валидирует граф, **не ставя задание в очередь** и не тратя GPU.
|
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** — в цепочках моделей
|
**исходном виде они не проходили валидацию ComfyUI** — в цепочках моделей
|
||||||
отсутствовали узлы, на которые шли ссылки:
|
отсутствовали узлы, на которые шли ссылки:
|
||||||
|
|
||||||
```
|
```
|
||||||
workflow_create.json workflow_edit.json
|
workflows/z_image_turbo.json workflows/qwen_image_edit.json
|
||||||
|
|
||||||
83:28 UNETLoader 115:37 UNETLoader
|
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'`.
|
Ошибка сервера: `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) |
|
| `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) |
|
||||||
| `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/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 в поле
|
Подключение LoRA во втором случае содержательно верно: в настройках OpenWebUI в поле
|
||||||
«Модель» редактирования указан именно Lightning-LoRA на 4 шага, а `steps: 4` в
|
«Модель» редактирования указан именно Lightning-LoRA на 4 шага, а `steps: 4` в
|
||||||
@@ -376,7 +430,7 @@ KSampler без него не имеет смысла.
|
|||||||
При смене workflow в OpenWebUI следует перезалить JSON сюда и сверить эти две
|
При смене workflow в OpenWebUI следует перезалить JSON сюда и сверить эти две
|
||||||
цепочки; локальная проверка на старте поймает проблему автоматически.
|
цепочки; локальная проверка на старте поймает проблему автоматически.
|
||||||
|
|
||||||
### Свойства шаблонов, которые стоит знать
|
### Свойства workflow, которые стоит знать
|
||||||
|
|
||||||
- **Размер выхода в edit ≠ `size`.** В графе за разрешение отвечает
|
- **Размер выхода в edit ≠ `size`.** В графе за разрешение отвечает
|
||||||
`ImageScaleToTotalPixels` (`megapixels: 1`), а latent из `EmptySD3LatentImage`
|
`ImageScaleToTotalPixels` (`megapixels: 1`), а latent из `EmptySD3LatentImage`
|
||||||
@@ -396,10 +450,10 @@ KSampler без него не имеет смысла.
|
|||||||
comfy-adapter/
|
comfy-adapter/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py FastAPI: эндпоинты, цикл execute, разбор multipart/JSON
|
│ ├── main.py FastAPI: эндпоинты, цикл execute, разбор multipart/JSON
|
||||||
│ ├── config.py настройки из env + дефолтная карта маппинга
|
│ ├── config.py настройки из env + загрузка реестра моделей из models.json
|
||||||
│ ├── comfy_client.py async-клиент ComfyUI: /prompt, /upload/image, /history, /view
|
│ ├── comfy_client.py async-клиент ComfyUI: /prompt, /upload/image, /history, /view
|
||||||
│ ├── injector.py deep-copy шаблона + подстановка значений, парсинг WxH, seed
|
│ ├── injector.py deep-copy workflow + замещение плейсхолдеров, парсинг WxH, seed
|
||||||
│ ├── templates.py загрузка шаблонов, проверка битых ссылок и соответствия карте
|
│ ├── templates.py загрузка реестра, проверка ссылок и токенов, выбор модели
|
||||||
│ ├── schemas.py pydantic-модели OpenAI-контракта
|
│ ├── schemas.py pydantic-модели OpenAI-контракта
|
||||||
│ ├── errors.py иерархия исключений + обработчики в формат OpenAI
|
│ ├── errors.py иерархия исключений + обработчики в формат OpenAI
|
||||||
│ └── auth.py проверка статического Bearer-ключа
|
│ └── auth.py проверка статического Bearer-ключа
|
||||||
@@ -408,15 +462,16 @@ comfy-adapter/
|
|||||||
│ ├── test_generations.py генерация: успех, инъекция значений, seed, все классы 400/502
|
│ ├── test_generations.py генерация: успех, инъекция значений, seed, все классы 400/502
|
||||||
│ ├── test_edits.py edits: multipart и base64, игнорирование size, ошибки входа
|
│ ├── test_edits.py edits: multipart и base64, игнорирование size, ошибки входа
|
||||||
│ ├── test_comfy_client.py каждый метод клиента на respx-моках (успех/HTTP-ошибка/таймаут)
|
│ ├── test_comfy_client.py каждый метод клиента на respx-моках (успех/HTTP-ошибка/таймаут)
|
||||||
│ ├── test_injector.py маппинг, deep-copy без мутации шаблона, seed, парсинг size
|
│ ├── test_injector.py плейсхолдеры, deep-copy без мутации, seed, парсинг size
|
||||||
│ ├── test_templates.py битые ссылки, сверка с картой, валидность репозиторных шаблонов
|
│ ├── test_templates.py битые ссылки, токены, совместимость и выбор модели, ошибки реестра
|
||||||
│ ├── test_config.py дефолты, переопределение из env, слияние MAPPING_FILE
|
│ ├── test_config.py дефолты, переопределение из env, загрузка и fail-fast реестра
|
||||||
│ ├── test_auth.py ключ: отсутствует / неверный / верный; health без ключа
|
│ ├── test_auth.py ключ: отсутствует / неверный / верный; health без ключа
|
||||||
│ ├── test_errors.py канонический вид error-body, соответствие статусов
|
│ ├── test_errors.py канонический вид error-body, соответствие статусов
|
||||||
│ ├── test_models.py формат /v1/models
|
│ ├── test_models.py формат /v1/models
|
||||||
|
│ ├── test_registry_integration.py новая модель только правкой models.json (без кода)
|
||||||
│ └── test_live.py живые прогоны против реального ComfyUI (маркер live)
|
│ └── test_live.py живые прогоны против реального ComfyUI (маркер live)
|
||||||
├── workflow_create.json исправленный шаблон генерации
|
├── models.json реестр API-моделей: id -> {workflow, owned_by}
|
||||||
├── workflow_edit.json исправленный шаблон редактирования
|
├── workflows/ workflow-файлы с плейсхолдерами (z_image_turbo.json, qwen_image_edit.json)
|
||||||
├── Dockerfile python:3.12-slim + uvicorn
|
├── Dockerfile python:3.12-slim + uvicorn
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
└── .env.example
|
└── .env.example
|
||||||
@@ -450,11 +505,14 @@ seed (повтор даёт идентичный base64), редактирова
|
|||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|
||||||
- **`n > 1` не поддерживается** -> 400. Нужен — правьте `batch_size` в шаблоне или
|
- **`n > 1` не поддерживается** -> 400. Нужен — правьте `batch_size` в workflow или
|
||||||
запускайте несколько запросов.
|
запускайте несколько запросов.
|
||||||
- **`size` в `/v1/images/edits` не влияет на выход** (см. «Свойства шаблонов»).
|
- **`size` в `/v1/images/edits` не влияет на выход** (см. «Свойства workflow»): edit-граф не
|
||||||
- **`negative_prompt` из запроса не принимается.** Узел в шаблоне есть, но поля в
|
содержит `{width}`/`{height}`, эндпоинт edits их не поставляет.
|
||||||
схеме запроса нет — адаптер всегда пишет пустую строку.
|
- **`negative_prompt` из запроса не принимается.** Поля в схеме запроса нет — токен
|
||||||
|
`{negative}`, если он есть в workflow, всегда резолвится в пустую строку `""`.
|
||||||
|
- **`model` не задан -> берётся первая совместимая с эндпоинтом модель** реестра (порядок
|
||||||
|
записей в `models.json` задаёт приоритет). Явный `default`-флаг пока не реализован.
|
||||||
- **Размер в генерации не квантуется.** Любое корректное `WxH` уходит в latent
|
- **Размер в генерации не квантуется.** Любое корректное `WxH` уходит в latent
|
||||||
напрямую; нестандартные значения могут дать артефакты или ошибку уже на стороне
|
напрямую; нестандартные значения могут дать артефакты или ошибку уже на стороне
|
||||||
ComfyUI (прилетит как 502, а не 400).
|
ComfyUI (прилетит как 502, а не 400).
|
||||||
|
|||||||
+32
-32
@@ -1,30 +1,20 @@
|
|||||||
"""Environment settings and the parameter -> workflow node mapping."""
|
"""Environment settings and the model registry loaded from a JSON file."""
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from .errors import ConfigError
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
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.
|
class ModelEntry(BaseModel):
|
||||||
# Override the whole map via MAPPING_FILE (JSON with "create" and "edit" keys).
|
"""A registry record: an API model id maps to one workflow file."""
|
||||||
DEFAULT_MAPPING: dict[str, dict[str, dict[str, str]]] = {
|
|
||||||
"create": {
|
workflow: Path
|
||||||
"prompt": {"node": "83:27", "field": "text"},
|
owned_by: str = "comfy-adapter"
|
||||||
"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):
|
class Settings(BaseSettings):
|
||||||
@@ -34,21 +24,31 @@ class Settings(BaseSettings):
|
|||||||
adapter_api_key: str = ""
|
adapter_api_key: str = ""
|
||||||
execution_timeout: float = 300.0
|
execution_timeout: float = 300.0
|
||||||
poll_interval: float = 1.5
|
poll_interval: float = 1.5
|
||||||
create_template_path: Path = REPO_ROOT / "workflow_create.json"
|
# JSON file mapping an API model id to its workflow. Required; no defaults in code.
|
||||||
edit_template_path: Path = REPO_ROOT / "workflow_edit.json"
|
models_file: Path = REPO_ROOT / "models.json"
|
||||||
mapping_file: Path | None = None
|
# Validate collected graphs on the ComfyUI server (POST /prompt?front=false) at startup.
|
||||||
# Validate graphs on the ComfyUI server (POST /prompt?front=false) at startup.
|
|
||||||
validate_templates_on_server: bool = True
|
validate_templates_on_server: bool = True
|
||||||
|
|
||||||
def mapping(self) -> dict[str, dict[str, dict[str, str]]]:
|
def load_registry(self) -> dict[str, ModelEntry]:
|
||||||
if self.mapping_file is None:
|
"""Load the model registry, preserving declaration order (first = preferred).
|
||||||
return DEFAULT_MAPPING
|
|
||||||
with open(self.mapping_file, encoding="utf-8") as f:
|
Fail-fast: a missing or malformed file raises ConfigError and blocks startup.
|
||||||
loaded = json.load(f)
|
"""
|
||||||
merged = {k: dict(v) for k, v in DEFAULT_MAPPING.items()}
|
try:
|
||||||
for section, section_map in loaded.items():
|
raw = json.loads(self.models_file.read_text(encoding="utf-8"))
|
||||||
merged.setdefault(section, {}).update(section_map)
|
except FileNotFoundError as e:
|
||||||
return merged
|
raise ConfigError(f"model registry file not found: {self.models_file}") from e
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ConfigError(f"model registry is not valid JSON ({self.models_file}): {e}") from e
|
||||||
|
if not isinstance(raw, dict) or not raw:
|
||||||
|
raise ConfigError(f"model registry must be a non-empty object of model entries: {self.models_file}")
|
||||||
|
registry: dict[str, ModelEntry] = {}
|
||||||
|
for model_id, spec in raw.items():
|
||||||
|
try:
|
||||||
|
registry[model_id] = ModelEntry.model_validate(spec)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ConfigError(f"model '{model_id}' has an invalid entry: {e}") from e
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ class ComfyUIError(AdapterError):
|
|||||||
super().__init__(502, message, "server_error", code)
|
super().__init__(502, message, "server_error", code)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(AdapterError):
|
||||||
|
"""Invalid model registry configuration; blocks startup."""
|
||||||
|
|
||||||
|
def __init__(self, message: str):
|
||||||
|
super().__init__(500, message, "server_error", "config_error")
|
||||||
|
|
||||||
|
|
||||||
def error_body(message: str, err_type: str, code: str | None = None) -> dict:
|
def error_body(message: str, err_type: str, code: str | None = None) -> dict:
|
||||||
return {"error": {"message": message, "type": err_type, "param": None, "code": code}}
|
return {"error": {"message": message, "type": err_type, "param": None, "code": code}}
|
||||||
|
|
||||||
|
|||||||
+73
-36
@@ -1,5 +1,12 @@
|
|||||||
"""Build a concrete workflow graph from a template + request parameters."""
|
"""Build a concrete workflow graph from a template + request values.
|
||||||
|
|
||||||
|
Values are injected through placeholder tokens written directly inside the
|
||||||
|
workflow graph (e.g. "{prompt}", "{seed}"), so the graph itself declares which
|
||||||
|
node input receives which request field. The set of recognised tokens is fixed
|
||||||
|
and owned by the adapter, not by the model.
|
||||||
|
"""
|
||||||
import copy
|
import copy
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -7,6 +14,21 @@ from .errors import InvalidRequest
|
|||||||
|
|
||||||
SEED_MAX = 2**63 - 1
|
SEED_MAX = 2**63 - 1
|
||||||
|
|
||||||
|
# Fixed token vocabulary owned by the adapter. A model's workflow may only use
|
||||||
|
# these tokens; anything else is rejected. Types drive typed vs string injection.
|
||||||
|
TOKEN_TYPES: dict[str, str] = {
|
||||||
|
"prompt": "str",
|
||||||
|
"negative": "str",
|
||||||
|
"seed": "int",
|
||||||
|
"width": "int",
|
||||||
|
"height": "int",
|
||||||
|
"image": "str",
|
||||||
|
}
|
||||||
|
KNOWN_TOKENS = frozenset(TOKEN_TYPES)
|
||||||
|
|
||||||
|
_TOKEN = re.compile(r"\{(\w+)\}")
|
||||||
|
_WHOLE = re.compile(r"^\{(\w+)\}$")
|
||||||
|
|
||||||
|
|
||||||
def parse_size(size: str) -> tuple[int, int]:
|
def parse_size(size: str) -> tuple[int, int]:
|
||||||
"""Parse OpenAI `WxH` size strings (e.g. '1024x1024')."""
|
"""Parse OpenAI `WxH` size strings (e.g. '1024x1024')."""
|
||||||
@@ -28,40 +50,55 @@ def new_seed(seed: int | None) -> int:
|
|||||||
return seed
|
return seed
|
||||||
|
|
||||||
|
|
||||||
def build_workflow(
|
def extract_tokens(graph: dict[str, Any]) -> tuple[set[str], list[str]]:
|
||||||
template: dict[str, Any],
|
"""Token names referenced by the graph.
|
||||||
mapping: dict[str, dict[str, str]],
|
|
||||||
*,
|
Returns (known_tokens, unknown_token_errors). A token is any `{name}` inside
|
||||||
prompt: str,
|
a string node input. Unknown names are reported so startup can reject the
|
||||||
seed: int,
|
workflow before it ever reaches a request.
|
||||||
size: str | None = None,
|
"""
|
||||||
image_name: str | None = None,
|
declared: set[str] = set()
|
||||||
negative: str = "",
|
unknown: set[str] = set()
|
||||||
) -> dict[str, Any]:
|
for node_id, node in graph.items():
|
||||||
"""Deep-copy the template and inject request values per the node mapping."""
|
for field, value in node.get("inputs", {}).items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
for name in _TOKEN.findall(value):
|
||||||
|
if name in KNOWN_TOKENS:
|
||||||
|
declared.add(name)
|
||||||
|
else:
|
||||||
|
unknown.add(f"node {node_id}: input '{field}' uses unknown token '{{{name}}}'")
|
||||||
|
return declared, sorted(unknown)
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow(template: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Deep-copy the template and substitute placeholder tokens with values.
|
||||||
|
|
||||||
|
Injection is type-preserving: a value that is exactly one token (e.g.
|
||||||
|
"{seed}") is replaced by the typed object (int for seed/width/height, str
|
||||||
|
otherwise); a token embedded in surrounding text is string-interpolated.
|
||||||
|
A referenced token that is unrecognized or missing from ``values`` rejects
|
||||||
|
the build, so a malformed graph never reaches ComfyUI.
|
||||||
|
"""
|
||||||
graph = copy.deepcopy(template)
|
graph = copy.deepcopy(template)
|
||||||
|
for node in graph.values():
|
||||||
def put(key: str, value: Any) -> None:
|
inputs = node.get("inputs", {})
|
||||||
spec = mapping.get(key)
|
for field, value in inputs.items():
|
||||||
if spec is None:
|
if isinstance(value, str):
|
||||||
return
|
inputs[field] = _substitute(value, values)
|
||||||
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
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def _substitute(value: str, values: dict[str, Any]) -> Any:
|
||||||
|
for name in _TOKEN.findall(value):
|
||||||
|
if name not in KNOWN_TOKENS:
|
||||||
|
raise InvalidRequest(f"workflow uses unrecognized token '{{{name}}}'")
|
||||||
|
if name not in values:
|
||||||
|
raise InvalidRequest(f"no value supplied for token '{{{name}}}'")
|
||||||
|
whole = _WHOLE.fullmatch(value)
|
||||||
|
if whole:
|
||||||
|
return _coerce(whole.group(1), values[whole.group(1)])
|
||||||
|
return _TOKEN.sub(lambda m: str(values[m.group(1)]), value)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(name: str, value: Any) -> Any:
|
||||||
|
return int(value) if TOKEN_TYPES[name] == "int" else str(value)
|
||||||
|
|||||||
+33
-28
@@ -8,9 +8,9 @@ from fastapi import FastAPI, Request
|
|||||||
|
|
||||||
from .auth import require_key
|
from .auth import require_key
|
||||||
from .comfy_client import ComfyClient, extract_output_image
|
from .comfy_client import ComfyClient, extract_output_image
|
||||||
from .config import AVAILABLE_MODELS, CREATE_MODEL, EDIT_MODEL, Settings, get_settings
|
from .config import Settings, get_settings
|
||||||
from .errors import InvalidRequest, install_error_handlers
|
from .errors import InvalidRequest, install_error_handlers
|
||||||
from .injector import build_workflow, new_seed
|
from .injector import build_workflow, new_seed, parse_size
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
ImageEditJsonRequest,
|
ImageEditJsonRequest,
|
||||||
ImageGenerationRequest,
|
ImageGenerationRequest,
|
||||||
@@ -19,32 +19,42 @@ from .schemas import (
|
|||||||
ModelCard,
|
ModelCard,
|
||||||
ModelsResponse,
|
ModelsResponse,
|
||||||
)
|
)
|
||||||
from .templates import load_all
|
from .templates import load_registry, select_model
|
||||||
|
|
||||||
logger = logging.getLogger("comfy-adapter")
|
logger = logging.getLogger("comfy-adapter")
|
||||||
|
|
||||||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||||
JPEG_MAGIC = b"\xff\xd8\xff"
|
JPEG_MAGIC = b"\xff\xd8\xff"
|
||||||
|
|
||||||
|
# Type-correct dummy values used to dry-inject every workflow for server validation.
|
||||||
|
_DUMMY_VALUES = {
|
||||||
|
"prompt": "x",
|
||||||
|
"negative": "",
|
||||||
|
"seed": 1,
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"image": "dummy.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
settings = settings or get_settings()
|
settings = settings or get_settings()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
create_graph, edit_graph = load_all(settings)
|
registry = load_registry(settings)
|
||||||
client = ComfyClient(settings.comfy_base_url)
|
client = ComfyClient(settings.comfy_base_url)
|
||||||
if settings.validate_templates_on_server:
|
if settings.validate_templates_on_server:
|
||||||
try:
|
for model in registry.values():
|
||||||
await client.validate_graph(create_graph)
|
try:
|
||||||
await client.validate_graph(edit_graph)
|
await client.validate_graph(build_workflow(model.graph, _DUMMY_VALUES))
|
||||||
logger.info("workflow templates validated on ComfyUI server")
|
logger.info("workflow validated on ComfyUI server: %s", model.id)
|
||||||
except Exception as e: # noqa: BLE001 - startup check is advisory, logged
|
except Exception as e: # noqa: BLE001 - startup check is advisory, logged
|
||||||
logger.error("server-side template validation failed: %s", e)
|
logger.error("server-side validation failed for model '%s': %s", model.id, e)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.comfy = client
|
app.state.comfy = client
|
||||||
app.state.templates = {"create": create_graph, "edit": edit_graph}
|
app.state.registry = registry
|
||||||
logger.info("comfy-adapter ready (server=%s)", settings.comfy_base_url)
|
logger.info("comfy-adapter ready (server=%s, models=%s)", settings.comfy_base_url, list(registry))
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -75,7 +85,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
@app.get("/v1/models")
|
@app.get("/v1/models")
|
||||||
async def models(request: Request) -> ModelsResponse:
|
async def models(request: Request) -> ModelsResponse:
|
||||||
require_key(request, _s(request))
|
require_key(request, _s(request))
|
||||||
return ModelsResponse(data=[ModelCard(id=m) for m in AVAILABLE_MODELS])
|
registry = request.app.state.registry
|
||||||
|
return ModelsResponse(
|
||||||
|
data=[ModelCard(id=model_id, owned_by=model.owned_by) for model_id, model in registry.items()]
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/v1/images/generations")
|
@app.post("/v1/images/generations")
|
||||||
async def generations(request: Request) -> ImagesResponse:
|
async def generations(request: Request) -> ImagesResponse:
|
||||||
@@ -86,14 +99,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
raise InvalidRequest("prompt must not be empty")
|
raise InvalidRequest("prompt must not be empty")
|
||||||
if body.n not in (None, 1):
|
if body.n not in (None, 1):
|
||||||
raise InvalidRequest("only n=1 is supported")
|
raise InvalidRequest("only n=1 is supported")
|
||||||
if body.model is not None and body.model != CREATE_MODEL:
|
model = select_model(request.app.state.registry, body.model, "generations")
|
||||||
raise InvalidRequest(f"unknown model '{body.model}', expected '{CREATE_MODEL}'")
|
width, height = parse_size(body.size)
|
||||||
graph = build_workflow(
|
graph = build_workflow(
|
||||||
app.state.templates["create"],
|
model.graph,
|
||||||
settings.mapping()["create"],
|
{"prompt": body.prompt, "seed": new_seed(body.seed), "negative": "", "width": width, "height": height},
|
||||||
prompt=body.prompt,
|
|
||||||
seed=new_seed(body.seed),
|
|
||||||
size=body.size,
|
|
||||||
)
|
)
|
||||||
return await _run(graph)
|
return await _run(graph)
|
||||||
|
|
||||||
@@ -112,18 +122,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
raise InvalidRequest("expected multipart/form-data or application/json body")
|
raise InvalidRequest("expected multipart/form-data or application/json body")
|
||||||
if not prompt.strip():
|
if not prompt.strip():
|
||||||
raise InvalidRequest("prompt must not be empty")
|
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)
|
_validate_image_bytes(image_bytes)
|
||||||
|
model = select_model(request.app.state.registry, model, "edits")
|
||||||
comfy: ComfyClient = app.state.comfy
|
comfy: ComfyClient = app.state.comfy
|
||||||
name = await comfy.upload_image(image_bytes)
|
name = await comfy.upload_image(image_bytes)
|
||||||
graph = build_workflow(
|
graph = build_workflow(
|
||||||
app.state.templates["edit"],
|
model.graph,
|
||||||
settings.mapping()["edit"],
|
{"prompt": prompt, "seed": new_seed(seed), "negative": "", "image": name},
|
||||||
prompt=prompt,
|
|
||||||
seed=new_seed(seed),
|
|
||||||
image_name=name,
|
|
||||||
negative="",
|
|
||||||
)
|
)
|
||||||
return await _run(graph)
|
return await _run(graph)
|
||||||
|
|
||||||
|
|||||||
+81
-31
@@ -1,9 +1,17 @@
|
|||||||
"""Template loading with startup validation of graph links and mappings."""
|
"""Model registry loading: read each workflow, validate links/tokens, and
|
||||||
|
decide which model an endpoint may run.
|
||||||
|
|
||||||
|
Startup validation of a workflow is local here (broken links + token check);
|
||||||
|
the collected graphs are then dry-injected and sent to the server elsewhere
|
||||||
|
(see main.lifespan).
|
||||||
|
"""
|
||||||
import json
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .errors import AdapterError
|
from .errors import AdapterError, ConfigError, InvalidRequest
|
||||||
|
from .injector import extract_tokens
|
||||||
|
|
||||||
|
|
||||||
class TemplateError(AdapterError):
|
class TemplateError(AdapterError):
|
||||||
@@ -13,6 +21,29 @@ class TemplateError(AdapterError):
|
|||||||
super().__init__(500, message, "server_error", "template_error")
|
super().__init__(500, message, "server_error", "template_error")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Model:
|
||||||
|
id: str
|
||||||
|
graph: dict
|
||||||
|
declared: frozenset[str]
|
||||||
|
owned_by: str = "comfy-adapter"
|
||||||
|
workflow: Path = field(default=Path("."))
|
||||||
|
|
||||||
|
|
||||||
|
# Token sets belong to the endpoint, not the model. A model is valid for an
|
||||||
|
# endpoint iff required(endpoint) <= declared(graph) <= supplied(endpoint).
|
||||||
|
ENDPOINT_VOCAB: dict[str, dict[str, frozenset[str]]] = {
|
||||||
|
"generations": {
|
||||||
|
"supplied": frozenset({"prompt", "negative", "seed", "width", "height"}),
|
||||||
|
"required": frozenset({"prompt"}),
|
||||||
|
},
|
||||||
|
"edits": {
|
||||||
|
"supplied": frozenset({"prompt", "negative", "seed", "image"}),
|
||||||
|
"required": frozenset({"prompt", "image"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def find_broken_links(graph: dict) -> list[str]:
|
def find_broken_links(graph: dict) -> list[str]:
|
||||||
"""References to node ids that do not exist in the graph."""
|
"""References to node ids that do not exist in the graph."""
|
||||||
errors = []
|
errors = []
|
||||||
@@ -24,39 +55,58 @@ def find_broken_links(graph: dict) -> list[str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def check_mapping(graph: dict, mapping: dict, name: str) -> list[str]:
|
def is_compatible(declared: frozenset[str], endpoint: str) -> bool:
|
||||||
"""Every mapped node id must exist with a scalar input field."""
|
vocab = ENDPOINT_VOCAB[endpoint]
|
||||||
errors = []
|
return vocab["required"] <= declared and declared <= vocab["supplied"]
|
||||||
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]:
|
def select_model(models: dict[str, Model], model: str | None, endpoint: str) -> Model:
|
||||||
if "width_field" in spec:
|
"""Pick the model for a request by id, or the first compatible one.
|
||||||
return [spec["width_field"], spec["height_field"]]
|
|
||||||
return [spec["field"]]
|
An explicit id that is unknown or incompatible with the endpoint is a 400,
|
||||||
|
as is a missing id when no registered model is compatible.
|
||||||
|
"""
|
||||||
|
if model is not None:
|
||||||
|
found = models.get(model)
|
||||||
|
if found is None:
|
||||||
|
raise InvalidRequest(f"unknown model '{model}'")
|
||||||
|
if not is_compatible(found.declared, endpoint):
|
||||||
|
raise InvalidRequest(f"model '{model}' is not compatible with this endpoint")
|
||||||
|
return found
|
||||||
|
for candidate in models.values(): # declaration order = priority
|
||||||
|
if is_compatible(candidate.declared, endpoint):
|
||||||
|
return candidate
|
||||||
|
raise InvalidRequest(f"no model in the registry is compatible with this endpoint")
|
||||||
|
|
||||||
|
|
||||||
def load_template(path: Path, graph_name: str, mapping: dict, mapping_name: str) -> dict:
|
def _read_graph(path: Path, model_id: str) -> dict:
|
||||||
with open(path, encoding="utf-8") as f:
|
try:
|
||||||
graph = json.load(f)
|
with open(path, encoding="utf-8") as f:
|
||||||
errors = find_broken_links(graph) + check_mapping(graph, mapping[mapping_name], mapping_name)
|
return json.load(f)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise TemplateError(f"model '{model_id}': workflow file not found: {path}") from e
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise TemplateError(f"model '{model_id}': workflow is not valid JSON ({path}): {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def load_model(model_id: str, workflow_path: Path, owned_by: str) -> Model:
|
||||||
|
graph = _read_graph(workflow_path, model_id)
|
||||||
|
errors = find_broken_links(graph)
|
||||||
|
declared, unknown = extract_tokens(graph)
|
||||||
|
errors += unknown
|
||||||
if errors:
|
if errors:
|
||||||
raise TemplateError(f"workflow template '{graph_name}' ({path}) is invalid:\n " + "\n ".join(errors))
|
raise TemplateError(
|
||||||
return graph
|
f"workflow for model '{model_id}' ({workflow_path}) is invalid:\n " + "\n ".join(errors)
|
||||||
|
)
|
||||||
|
return Model(id=model_id, graph=graph, declared=frozenset(declared), owned_by=owned_by, workflow=workflow_path)
|
||||||
|
|
||||||
|
|
||||||
def load_all(settings: Settings) -> tuple[dict, dict]:
|
def load_registry(settings: Settings) -> dict[str, Model]:
|
||||||
mapping = settings.mapping()
|
"""Load every model in the registry, preserving order for default selection."""
|
||||||
create = load_template(
|
base_dir = settings.models_file.parent
|
||||||
settings.create_template_path, "workflow_create.json", mapping, "create"
|
registry = settings.load_registry()
|
||||||
)
|
models: dict[str, Model] = {}
|
||||||
edit = load_template(settings.edit_template_path, "workflow_edit.json", mapping, "edit")
|
for model_id, entry in registry.items():
|
||||||
return create, edit
|
path = entry.workflow if entry.workflow.is_absolute() else base_dir / entry.workflow
|
||||||
|
models[model_id] = load_model(model_id, path, entry.owned_by)
|
||||||
|
return models
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-11
@@ -1,4 +1,7 @@
|
|||||||
from app.config import DEFAULT_MAPPING, Settings
|
import pytest
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.errors import ConfigError
|
||||||
|
|
||||||
|
|
||||||
def test_defaults():
|
def test_defaults():
|
||||||
@@ -6,7 +9,7 @@ def test_defaults():
|
|||||||
assert s.comfy_base_url == "http://192.168.0.3:8188"
|
assert s.comfy_base_url == "http://192.168.0.3:8188"
|
||||||
assert s.execution_timeout == 300.0
|
assert s.execution_timeout == 300.0
|
||||||
assert s.poll_interval == 1.5
|
assert s.poll_interval == 1.5
|
||||||
assert s.mapping() == DEFAULT_MAPPING
|
assert s.models_file.name == "models.json"
|
||||||
|
|
||||||
|
|
||||||
def test_env_override(monkeypatch):
|
def test_env_override(monkeypatch):
|
||||||
@@ -21,12 +24,40 @@ def test_env_override(monkeypatch):
|
|||||||
assert s.poll_interval == 0.5
|
assert s.poll_interval == 0.5
|
||||||
|
|
||||||
|
|
||||||
def test_mapping_file_override(tmp_path):
|
def test_registry_loads_shipped_in_order():
|
||||||
f = tmp_path / "mapping.json"
|
s = Settings(_env_file=None)
|
||||||
f.write_text('{"create": {"prompt": {"node": "1", "field": "t"}}}', encoding="utf-8")
|
registry = s.load_registry()
|
||||||
s = Settings(_env_file=None, mapping_file=f)
|
# order preserved (first = preferred for default selection)
|
||||||
m = s.mapping()
|
assert list(registry) == ["z-image-turbo", "qwen-image-edit"]
|
||||||
assert m["create"]["prompt"] == {"node": "1", "field": "t"}
|
assert registry["z-image-turbo"].workflow.name == "z_image_turbo.json"
|
||||||
# other sections/keys keep defaults
|
assert registry["z-image-turbo"].owned_by == "comfy-adapter"
|
||||||
assert m["edit"] == DEFAULT_MAPPING["edit"]
|
|
||||||
assert m["create"]["seed"] == DEFAULT_MAPPING["create"]["seed"]
|
|
||||||
|
def test_registry_missing_file_fails_fast(tmp_path):
|
||||||
|
s = Settings(_env_file=None, models_file=tmp_path / "nope.json")
|
||||||
|
with pytest.raises(ConfigError, match="not found"):
|
||||||
|
s.load_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_malformed_json_fails_fast(tmp_path):
|
||||||
|
f = tmp_path / "models.json"
|
||||||
|
f.write_text("{not valid", encoding="utf-8")
|
||||||
|
s = Settings(_env_file=None, models_file=f)
|
||||||
|
with pytest.raises(ConfigError, match="not valid JSON"):
|
||||||
|
s.load_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_empty_fails_fast(tmp_path):
|
||||||
|
f = tmp_path / "models.json"
|
||||||
|
f.write_text("{}", encoding="utf-8")
|
||||||
|
s = Settings(_env_file=None, models_file=f)
|
||||||
|
with pytest.raises(ConfigError, match="non-empty"):
|
||||||
|
s.load_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_bad_entry_fails_fast(tmp_path):
|
||||||
|
f = tmp_path / "models.json"
|
||||||
|
f.write_text('{"m": {"not_workflow": "x.json"}}', encoding="utf-8")
|
||||||
|
s = Settings(_env_file=None, models_file=f)
|
||||||
|
with pytest.raises(ConfigError, match="invalid entry"):
|
||||||
|
s.load_registry()
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ async def test_unknown_model_400(client):
|
|||||||
await _assert_400(await client.post(GEN, json={"prompt": "x", "model": "dall-e-3"}))
|
await _assert_400(await client.post(GEN, json={"prompt": "x", "model": "dall-e-3"}))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_incompatible_model_400(client):
|
||||||
|
# qwen-image-edit declares {image}, which generations cannot supply
|
||||||
|
await _assert_400(await client.post(GEN, json={"prompt": "x", "model": "qwen-image-edit"}))
|
||||||
|
|
||||||
|
|
||||||
async def test_bad_json_400(client):
|
async def test_bad_json_400(client):
|
||||||
await _assert_400(await client.post(GEN, content=b"{not json", headers={"Content-Type": "application/json"}))
|
await _assert_400(await client.post(GEN, content=b"{not json", headers={"Content-Type": "application/json"}))
|
||||||
|
|
||||||
|
|||||||
+40
-25
@@ -1,15 +1,14 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.config import DEFAULT_MAPPING
|
from app.config import Settings
|
||||||
from app.errors import InvalidRequest
|
from app.errors import InvalidRequest
|
||||||
from app.injector import build_workflow, new_seed, parse_size
|
from app.injector import build_workflow, new_seed, parse_size
|
||||||
from app.templates import load_all
|
from app.templates import load_registry
|
||||||
from app.config import Settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def templates():
|
def registry():
|
||||||
return load_all(Settings(_env_file=None))
|
return load_registry(Settings(_env_file=None))
|
||||||
|
|
||||||
|
|
||||||
def test_parse_size():
|
def test_parse_size():
|
||||||
@@ -33,41 +32,57 @@ def test_seed_fixed_and_range():
|
|||||||
new_seed(-1)
|
new_seed(-1)
|
||||||
|
|
||||||
|
|
||||||
def test_create_injection(templates):
|
def test_create_injection_and_template_untouched(registry):
|
||||||
create, _ = templates
|
create = registry["z-image-turbo"].graph
|
||||||
mapping = DEFAULT_MAPPING["create"]
|
graph = build_workflow(create, {"prompt": "a cat", "seed": 77, "negative": "", "width": 832, "height": 1216})
|
||||||
graph = build_workflow(create, mapping, prompt="a cat", seed=77, size="832x1216")
|
|
||||||
assert graph["83:27"]["inputs"]["text"] == "a cat"
|
assert graph["83:27"]["inputs"]["text"] == "a cat"
|
||||||
assert graph["83:13"]["inputs"]["width"] == 832
|
assert graph["83:13"]["inputs"]["width"] == 832
|
||||||
assert graph["83:13"]["inputs"]["height"] == 1216
|
assert graph["83:13"]["inputs"]["height"] == 1216
|
||||||
assert graph["83:3"]["inputs"]["seed"] == 77
|
assert graph["83:3"]["inputs"]["seed"] == 77
|
||||||
# template untouched
|
# template still holds placeholders (deep copy, no mutation)
|
||||||
assert create["83:27"]["inputs"]["text"] == "\n"
|
assert create["83:27"]["inputs"]["text"] == "{prompt}"
|
||||||
assert create["83:13"]["inputs"]["width"] == 1280
|
assert create["83:13"]["inputs"]["width"] == "{width}"
|
||||||
|
assert create["83:3"]["inputs"]["seed"] == "{seed}"
|
||||||
|
|
||||||
|
|
||||||
def test_edit_injection(templates):
|
def test_injection_is_typed(registry):
|
||||||
_, edit = templates
|
graph = build_workflow(registry["z-image-turbo"].graph, {"prompt": "x", "seed": 7, "negative": "", "width": 64, "height": 128})
|
||||||
mapping = DEFAULT_MAPPING["edit"]
|
assert isinstance(graph["83:3"]["inputs"]["seed"], int) # numeric token -> int, not "{seed}"
|
||||||
graph = build_workflow(edit, mapping, prompt="make it night", seed=5, image_name="adapter_x.png")
|
assert isinstance(graph["83:13"]["inputs"]["width"], int)
|
||||||
|
assert isinstance(graph["83:27"]["inputs"]["text"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedded_token_is_string_interpolated():
|
||||||
|
graph = {"1": {"class_type": "X", "inputs": {"text": "prefix {prompt} suffix"}}}
|
||||||
|
out = build_workflow(graph, {"prompt": "hi"})
|
||||||
|
assert out["1"]["inputs"]["text"] == "prefix hi suffix"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_injection(registry):
|
||||||
|
edit = registry["qwen-image-edit"].graph
|
||||||
|
graph = build_workflow(edit, {"prompt": "make it night", "seed": 5, "negative": "", "image": "adapter_x.png"})
|
||||||
assert graph["115:111"]["inputs"]["prompt"] == "make it night"
|
assert graph["115:111"]["inputs"]["prompt"] == "make it night"
|
||||||
assert graph["115:110"]["inputs"]["prompt"] == ""
|
assert graph["115:110"]["inputs"]["prompt"] == ""
|
||||||
assert graph["78"]["inputs"]["image"] == "adapter_x.png"
|
assert graph["78"]["inputs"]["image"] == "adapter_x.png"
|
||||||
assert graph["115:3"]["inputs"]["seed"] == 5
|
assert graph["115:3"]["inputs"]["seed"] == 5
|
||||||
|
|
||||||
|
|
||||||
def test_edit_seed_varies_per_call(templates):
|
def test_edit_seed_varies_per_call(registry):
|
||||||
_, edit = templates
|
edit = registry["qwen-image-edit"].graph
|
||||||
mapping = DEFAULT_MAPPING["edit"]
|
|
||||||
seeds = set()
|
seeds = set()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
g = build_workflow(edit, mapping, prompt="p", seed=new_seed(None))
|
g = build_workflow(edit, {"prompt": "p", "seed": new_seed(None), "negative": "", "image": "a.png"})
|
||||||
seeds.add(g["115:3"]["inputs"]["seed"])
|
seeds.add(g["115:3"]["inputs"]["seed"])
|
||||||
assert len(seeds) == 3
|
assert len(seeds) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_mapping_node_raises(templates):
|
def test_unrecognized_token_rejected():
|
||||||
create, _ = templates
|
graph = {"1": {"class_type": "X", "inputs": {"text": "{foo}"}}}
|
||||||
bad_mapping = {"prompt": {"node": "999", "field": "text"}}
|
with pytest.raises(InvalidRequest, match="unrecognized token"):
|
||||||
with pytest.raises(InvalidRequest, match="999"):
|
build_workflow(graph, {"prompt": "p"})
|
||||||
build_workflow(create, bad_mapping, prompt="p", seed=1)
|
|
||||||
|
|
||||||
|
def test_unfilled_token_rejected():
|
||||||
|
graph = {"1": {"class_type": "X", "inputs": {"seed": "{seed}"}}}
|
||||||
|
with pytest.raises(InvalidRequest, match="no value supplied"):
|
||||||
|
build_workflow(graph, {"prompt": "p"})
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""A third model added purely by editing models.json (no code change): it shows up
|
||||||
|
in /v1/models, is dry-inject validated at startup, and is selectable by id."""
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from app.config import REPO_ROOT, Settings
|
||||||
|
from app.main import create_app
|
||||||
|
from tests.conftest import COMFY, FAKE_KEY
|
||||||
|
|
||||||
|
PROMPT_ID = "p-int"
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_generation_workflow() -> dict:
|
||||||
|
# declared = {prompt, negative, seed, width, height} -> generations-compatible, no {image}
|
||||||
|
return {
|
||||||
|
"1": {
|
||||||
|
"class_type": "X",
|
||||||
|
"inputs": {
|
||||||
|
"text": "{prompt}",
|
||||||
|
"neg": "{negative}",
|
||||||
|
"sd": "{seed}",
|
||||||
|
"w": "{width}",
|
||||||
|
"h": "{height}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _models_file(tmp_path) -> Settings:
|
||||||
|
(tmp_path / "extra.json").write_text(json.dumps(_extra_generation_workflow()), encoding="utf-8")
|
||||||
|
registry = {
|
||||||
|
"z-image-turbo": {"workflow": str(REPO_ROOT / "workflows" / "z_image_turbo.json")},
|
||||||
|
"extra-gen": {"workflow": "extra.json"}, # relative to models.json dir
|
||||||
|
"qwen-image-edit": {"workflow": str(REPO_ROOT / "workflows" / "qwen_image_edit.json")},
|
||||||
|
}
|
||||||
|
models = tmp_path / "models.json"
|
||||||
|
models.write_text(json.dumps(registry), encoding="utf-8")
|
||||||
|
return Settings(
|
||||||
|
_env_file=None,
|
||||||
|
comfy_base_url=COMFY,
|
||||||
|
adapter_api_key=FAKE_KEY,
|
||||||
|
models_file=models,
|
||||||
|
validate_templates_on_server=True,
|
||||||
|
poll_interval=0.0,
|
||||||
|
execution_timeout=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_lifespan(app):
|
||||||
|
stack = contextlib.AsyncExitStack()
|
||||||
|
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"] = f"Bearer {FAKE_KEY}"
|
||||||
|
return stack, ac
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_third_model_listed_and_validated_without_code_change(tmp_path):
|
||||||
|
validate_route = respx.post(f"{COMFY}/prompt").mock(return_value=httpx.Response(200, json={"prompt_id": PROMPT_ID}))
|
||||||
|
app = create_app(_models_file(tmp_path))
|
||||||
|
stack, ac = await _with_lifespan(app)
|
||||||
|
try:
|
||||||
|
ids = [m["id"] for m in (await ac.get("/v1/models")).json()["data"]]
|
||||||
|
assert ids == ["z-image-turbo", "extra-gen", "qwen-image-edit"]
|
||||||
|
# every model dry-injected and validated once at startup
|
||||||
|
assert validate_route.call_count == 3
|
||||||
|
finally:
|
||||||
|
await stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_extra_model_selectable_and_injects(tmp_path):
|
||||||
|
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={PROMPT_ID: {"outputs": {"1": {"images": [{"filename": "o.png", "subfolder": "", "type": "output"}]}},
|
||||||
|
"status": {"status_str": "success", "completed": True}}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
respx.get(f"{COMFY}/view").mock(return_value=httpx.Response(200, content=b"\x89PNG\r\n\x1a\n"))
|
||||||
|
|
||||||
|
settings = _models_file(tmp_path).model_copy(update={"validate_templates_on_server": False})
|
||||||
|
app = create_app(settings)
|
||||||
|
stack, ac = await _with_lifespan(app)
|
||||||
|
try:
|
||||||
|
r = await ac.post(
|
||||||
|
"http://adapter.test/v1/images/generations",
|
||||||
|
json={"model": "extra-gen", "prompt": "hello", "size": "512x768", "seed": 9},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
sent = json.loads(prompt_route.calls.last.request.content.decode())["prompt"]
|
||||||
|
assert sent["1"]["inputs"]["text"] == "hello"
|
||||||
|
assert sent["1"]["inputs"]["sd"] == 9
|
||||||
|
assert sent["1"]["inputs"]["w"] == 512 and sent["1"]["inputs"]["h"] == 768
|
||||||
|
finally:
|
||||||
|
await stack.aclose()
|
||||||
+80
-24
@@ -1,7 +1,15 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.templates import TemplateError, check_mapping, find_broken_links, load_all
|
from app.errors import InvalidRequest
|
||||||
|
from app.injector import extract_tokens
|
||||||
|
from app.templates import (
|
||||||
|
TemplateError,
|
||||||
|
find_broken_links,
|
||||||
|
is_compatible,
|
||||||
|
load_registry,
|
||||||
|
select_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_find_broken_links_detects_missing_node():
|
def test_find_broken_links_detects_missing_node():
|
||||||
@@ -22,36 +30,84 @@ def test_find_broken_links_ok_on_valid_graph():
|
|||||||
assert find_broken_links(graph) == []
|
assert find_broken_links(graph) == []
|
||||||
|
|
||||||
|
|
||||||
def test_check_mapping_missing_node():
|
def test_extract_tokens_reports_known_and_unknown():
|
||||||
graph = {"1": {"class_type": "X", "inputs": {"text": ""}}}
|
graph = {
|
||||||
mapping = {"create": {"prompt": {"node": "404", "field": "text"}}}
|
"1": {"class_type": "X", "inputs": {"text": "{prompt}", "seed": "{seed}"}},
|
||||||
errors = check_mapping(graph, mapping["create"], "create")
|
"2": {"class_type": "Y", "inputs": {"note": "static {notatoken} text"}},
|
||||||
assert any("not found" in e for e in errors)
|
}
|
||||||
|
declared, unknown = extract_tokens(graph)
|
||||||
|
assert declared == {"prompt", "seed"}
|
||||||
|
assert len(unknown) == 1 and "notatoken" in unknown[0]
|
||||||
|
|
||||||
|
|
||||||
def test_check_mapping_non_scalar_field():
|
def test_shipped_registry_loads_with_expected_tokens():
|
||||||
graph = {"1": {"class_type": "X", "inputs": {"text": ["2", 0]}}}
|
registry = load_registry(Settings(_env_file=None))
|
||||||
mapping = {"create": {"prompt": {"node": "1", "field": "text"}}}
|
assert set(registry) == {"z-image-turbo", "qwen-image-edit"}
|
||||||
errors = check_mapping(graph, mapping["create"], "create")
|
assert registry["z-image-turbo"].declared == frozenset({"prompt", "seed", "width", "height"})
|
||||||
assert any("no scalar input" in e for e in errors)
|
assert registry["qwen-image-edit"].declared == frozenset({"prompt", "negative", "seed", "image"})
|
||||||
|
|
||||||
|
|
||||||
def test_shipped_templates_load_cleanly():
|
def test_generations_graph_incompatible_with_edits():
|
||||||
s = Settings(_env_file=None)
|
assert not is_compatible(frozenset({"prompt", "seed", "width", "height"}), "edits")
|
||||||
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):
|
def test_edits_graph_incompatible_with_generations():
|
||||||
|
assert not is_compatible(frozenset({"prompt", "negative", "seed", "image"}), "generations")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_model_defaults(registry):
|
||||||
|
assert select_model(registry, None, "generations").id == "z-image-turbo"
|
||||||
|
assert select_model(registry, None, "edits").id == "qwen-image-edit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_model_explicit(registry):
|
||||||
|
assert select_model(registry, "qwen-image-edit", "edits").id == "qwen-image-edit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_model_unknown_400(registry):
|
||||||
|
with pytest.raises(InvalidRequest, match="unknown model"):
|
||||||
|
select_model(registry, "dall-e-3", "generations")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_model_incompatible_400(registry):
|
||||||
|
with pytest.raises(InvalidRequest, match="not compatible"):
|
||||||
|
select_model(registry, "qwen-image-edit", "generations")
|
||||||
|
with pytest.raises(InvalidRequest, match="not compatible"):
|
||||||
|
select_model(registry, "z-image-turbo", "edits")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_model_no_suitable_400(registry):
|
||||||
|
gen_only = {"z-image-turbo": registry["z-image-turbo"]}
|
||||||
|
with pytest.raises(InvalidRequest, match="no model"):
|
||||||
|
select_model(gen_only, None, "edits")
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_workflow_blocks_startup(tmp_path):
|
||||||
bad = tmp_path / "bad.json"
|
bad = tmp_path / "bad.json"
|
||||||
bad.write_text('{"1": {"class_type": "X", "inputs": {"m": ["9", 0]}}}', encoding="utf-8")
|
bad.write_text('{"1": {"class_type": "X", "inputs": {"m": ["9", 0]}}}', encoding="utf-8")
|
||||||
good = tmp_path / "good.json"
|
models = tmp_path / "models.json"
|
||||||
good.write_text(
|
models.write_text('{"m1": {"workflow": "bad.json"}}', encoding="utf-8")
|
||||||
'{"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:
|
with pytest.raises(TemplateError) as exc:
|
||||||
load_all(s)
|
load_registry(Settings(_env_file=None, models_file=models))
|
||||||
assert "missing node '9'" in str(exc.value)
|
assert "missing node '9'" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_token_blocks_startup(tmp_path):
|
||||||
|
wf = tmp_path / "wf.json"
|
||||||
|
wf.write_text('{"1": {"class_type": "X", "inputs": {"text": "{bogus}"}}}', encoding="utf-8")
|
||||||
|
models = tmp_path / "models.json"
|
||||||
|
models.write_text('{"m1": {"workflow": "wf.json"}}', encoding="utf-8")
|
||||||
|
with pytest.raises(TemplateError, match="unknown token"):
|
||||||
|
load_registry(Settings(_env_file=None, models_file=models))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_workflow_file_blocks_startup(tmp_path):
|
||||||
|
models = tmp_path / "models.json"
|
||||||
|
models.write_text('{"m1": {"workflow": "absent.json"}}', encoding="utf-8")
|
||||||
|
with pytest.raises(TemplateError, match="not found"):
|
||||||
|
load_registry(Settings(_env_file=None, models_file=models))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def registry():
|
||||||
|
return load_registry(Settings(_env_file=None))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"_meta": { "title": "Save Image" }
|
"_meta": { "title": "Save Image" }
|
||||||
},
|
},
|
||||||
"78": {
|
"78": {
|
||||||
"inputs": { "image": "" },
|
"inputs": { "image": "{image}" },
|
||||||
"class_type": "LoadImage",
|
"class_type": "LoadImage",
|
||||||
"_meta": { "title": "Load Image" }
|
"_meta": { "title": "Load Image" }
|
||||||
},
|
},
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
},
|
},
|
||||||
"115:110": {
|
"115:110": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": "",
|
"prompt": "{negative}",
|
||||||
"clip": ["115:38", 0],
|
"clip": ["115:38", 0],
|
||||||
"vae": ["115:39", 0],
|
"vae": ["115:39", 0],
|
||||||
"image1": ["115:93", 0]
|
"image1": ["115:93", 0]
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
"115:111": {
|
"115:111": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"prompt": "",
|
"prompt": "{prompt}",
|
||||||
"clip": ["115:38", 0],
|
"clip": ["115:38", 0],
|
||||||
"vae": ["115:39", 0],
|
"vae": ["115:39", 0],
|
||||||
"image1": ["115:93", 0]
|
"image1": ["115:93", 0]
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
"115:3": {
|
"115:3": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"seed": 1118877715456453,
|
"seed": "{seed}",
|
||||||
"steps": 4,
|
"steps": 4,
|
||||||
"cfg": 1,
|
"cfg": 1,
|
||||||
"sampler_name": "euler",
|
"sampler_name": "euler",
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
},
|
},
|
||||||
"83:13": {
|
"83:13": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"width": 1280,
|
"width": "{width}",
|
||||||
"height": 1280,
|
"height": "{height}",
|
||||||
"batch_size": 1
|
"batch_size": 1
|
||||||
},
|
},
|
||||||
"class_type": "EmptySD3LatentImage",
|
"class_type": "EmptySD3LatentImage",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
"83:3": {
|
"83:3": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"seed": 578351132403975,
|
"seed": "{seed}",
|
||||||
"steps": 9,
|
"steps": 9,
|
||||||
"cfg": 1,
|
"cfg": 1,
|
||||||
"sampler_name": "res_multistep",
|
"sampler_name": "res_multistep",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
"83:27": {
|
"83:27": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "\n",
|
"text": "{prompt}",
|
||||||
"clip": ["83:30", 0]
|
"clip": ["83:30", 0]
|
||||||
},
|
},
|
||||||
"class_type": "CLIPTextEncode",
|
"class_type": "CLIPTextEncode",
|
||||||
Reference in New Issue
Block a user