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

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
+3 -2
View File
@@ -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
View File
@@ -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
+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` | | `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
View File
@@ -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:
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+5
View File
@@ -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
View File
@@ -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"})
+101
View File
@@ -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
View File
@@ -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",