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

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
# Poll interval for /history, seconds
POLL_INTERVAL=1.5
# Optional: JSON file overriding node mappings (see app/config.py defaults)
# MAPPING_FILE=mapping.json
# Model registry: JSON mapping an API model id to its workflow file (required).
# 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 app ./app
COPY workflow_create.json workflow_edit.json ./
COPY models.json ./
COPY workflows ./workflows
RUN pip install --no-cache-dir .
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` |
| `qwen-image-edit` | редактирование входного изображения | `qwen_image_edit_2509_fp8_e4m3fn.safetensors` | LoRA `Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16` (4 шага), CLIP `qwen_2.5_vl_7b_fp8_scaled` (qwen_image), VAE `qwen_image_vae` |
@@ -37,7 +43,7 @@
- **`GET /v1/models`** — список доступных «моделей» (для автодополнения в клиентах).
- **Воспроизводимость**: опциональный `seed`; один и тот же `seed` + промпт дают идентичный результат.
- **Толерантный парсинг OpenAI**: неприменимые поля (`quality`, `style`, `user`) не ломают запрос.
- **Workflow как данные**: шаблоны JSON и карта маппинга параметров на узлы вынесены из кода.
- **Workflow как данные**: реестр моделей `models.json` и workflow-файлы с плейсхолдерами вынесены из кода.
- **Защита от деградации графа**: битые ссылки в шаблоне обнаруживаются локально и на сервере, адаптер не стартует с нерабочим workflow.
- **Статический API-ключ** на всех эндпоинтах, кроме `GET /health`.
- **Ошибки в формате OpenAI** (`error.message/type/param/code`) — клиенты корректно их разбирают.
@@ -87,10 +93,8 @@ curl -s http://localhost:8000/health
| `ADAPTER_API_KEY` | пусто (**обязательно**) | Ожидаемый Bearer-ключ клиентов. Пустое значение = все запросы получают 401 (fail-closed) |
| `EXECUTION_TIMEOUT` | `300` | Секунд ожидания результата от ComfyUI; превышение -> 502 `comfyui_timeout` |
| `POLL_INTERVAL` | `1.5` | Интервал опроса `/history`, сек |
| `CREATE_TEMPLATE_PATH` | `<репо>/workflow_create.json` | Путь к шаблону генерации |
| `EDIT_TEMPLATE_PATH` | `<репо>/workflow_edit.json` | Путь к шаблону редактирования |
| `MAPPING_FILE` | не задан | JSON с переопределением карты маппинга (см. ниже) |
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Прогонять графы через `POST /prompt?front=false` при старте |
| `MODELS_FILE` | `<репо>/models.json` | Реестр моделей: JSON `id -> {workflow, owned_by}` (обязателен, см. «Реестр моделей») |
| `VALIDATE_TEMPLATES_ON_SERVER` | `true` | Прогонять собранные графы через `POST /prompt?front=false` при старте |
Логирование стартовых проверок видно с `uvicorn --log-level info`:
@@ -121,7 +125,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
| Параметр | Тип | По умолчанию | Обязателен | Описание |
|---|---|---|---|---|
| `prompt` | string | — | **да** | Текстовое описание. Пустая строка -> 400 |
| `model` | string \| null | `null` | нет | Если задан — только `z-image-turbo`, иначе 400 |
| `model` | string \| null | `null` | нет | Id модели из реестра; пусто -> первая совместимая с генерацией (`z-image-turbo`). Неизвестная/несовместимая -> 400 |
| `n` | int \| null | `1` | нет | Поддерживается только `1`, иначе 400 |
| `size` | string | `1024x1024` | нет | Строго `WxH`, оба значения > 0. Попала в latent-ноду, то есть реально задаёт разрешение |
| `seed` | int \| null | `null` | нет | `0..2^63-1`. Без него — криптографически случайный (`secrets.randbelow`) |
@@ -146,7 +150,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
|---|---|---|---|
| `image` | файл | **да** | PNG или JPEG (проверка по сигнатуре байтов) |
| `prompt` | string | **да** | Инструкция по редактированию |
| `model` | string | нет | Только `qwen-image-edit` |
| `model` | string | нет | Id из реестра, совместимый с edits; пусто -> первая такая (`qwen-image-edit`). Иначе 400 |
| `seed` | int | нет | В форме передаётся строкой, парсится в int |
**application/json:**
@@ -155,7 +159,7 @@ Authorization: Bearer <ADAPTER_API_KEY>
|---|---|---|---|
| `image` | string (base64) | **да** | Допускается data-URL-префикс `data:image/png;base64,...` |
| `prompt` | string | **да** | |
| `model` | string | нет | Только `qwen-image-edit` |
| `model` | string | нет | Id из реестра, совместимый с edits; пусто -> первая такая (`qwen-image-edit`). Иначе 400 |
| `seed` | int | нет | |
| `size` | string | нет | **Принимается, но не влияет на результат** — см. «Ограничения» |
@@ -283,7 +287,7 @@ request
[1] auth + валидация тела --> 400 / 401
|
v
[2] deep-copy шаблона workflow, подстановка prompt/size/seed/image по карте маппинга
[2] deep-copy workflow выбранной модели, замещение плейсхолдеров {prompt}/{seed}/{width}/{height}/{image} значениями запроса
|
v
[3] (только edits) POST /upload/image -> имя файла в input-каталоге ComfyUI
@@ -306,49 +310,98 @@ WebSocket `/ws` намеренно не используется: ComfyUI гар
соединение и устойчив к разрывам. Транспорт изолирован в `app/comfy_client.py` —
переход на websocket не затронул бы остальной код.
### Карта маппинга
### Реестр моделей (models.json)
Соответствие параметра запроса узлу workflow. Правится **без изменения кода** через
`MAPPING_FILE` (JSON с секциями `create` и `edit`; заданные ключи перезаписывают
дефолт, остальные сохраняются).
| Параметр | `workflow_create.json` | `workflow_edit.json` |
|---|---|---|
| `prompt` | `83:27` -> `inputs.text` (CLIPTextEncode) | `115:111` -> `inputs.prompt` (TextEncodeQwenImageEditPlus) |
| negative | — (в графе `ConditioningZeroOut`, константа) | `115:110` -> `inputs.prompt` (всегда `""`) |
| `size` | `83:13` -> `inputs.width` / `inputs.height` (EmptySD3LatentImage) | — (см. «Ограничения») |
| `seed` | `83:3` -> `inputs.seed` (KSampler) | `115:3` -> `inputs.seed` (KSampler) |
| `image` | — | `78` -> `inputs.image` (LoadImage) |
Пример переопределения:
Список API-моделей задаётся **декларативно**, в файле `models.json` (путь — env
`MODELS_FILE`, по умолчанию `<репо>/models.json`). Каждая запись связывает id модели с её
workflow-файлом:
```json
{ "create": { "prompt": { "node": "99:1", "field": "text" } } }
{
"z-image-turbo": { "workflow": "workflows/z_image_turbo.json", "owned_by": "comfy-adapter" },
"qwen-image-edit": { "workflow": "workflows/qwen_image_edit.json", "owned_by": "comfy-adapter" }
}
```
При старте каждый id из карты проверяется на существование в шаблоне и на то, что
поле скалярное, — рассинхрон карты и шаблона падает на старт, а не на первый запрос.
- **Добавить модель = добавить запись + файл workflow. Код править не нужно.**
- `workflow` — путь к JSON (относительный разрешается относительно папки `models.json`).
- `owned_by` необязателен (по умолчанию `comfy-adapter`); используется в `GET /v1/models`.
- **Порядок записей значим**: он задаёт приоритет выбора при `model` не задан.
- Файл обязателен: отсутствующий или битый `models.json` -> явная ошибка конфигурации на
старте (fail-fast), сервис не поднимется молча.
### Проверка шаблонов
**Выбор модели.** Поле `model` выбирает запись реестра. Если `model` не задан или `null` —
берётся **первая совместимая с эндпоинтом** модель. Неизвестный id или несовместимый с
эндпоинтом -> `400`. Совместимость вычисляется из набора токенов (см. ниже), отдельного
поля «тип модели» в реестре нет.
Двухуровневая, оба уровня на старте:
**Совместимость модели с эндпоинтом** — вложенность множеств токенов, наборы принадлежат
эндпоинту:
1. **Локально** (`app/templates.py`): обход всех `inputs` и поиск ссылок
`[node_id, slot]` на несуществующие узлы. Найденное -> исключение со списком
`node X: input 'Y' links to missing node 'Z'`, адаптер не стартует.
2. **На сервере** (`VALIDATE_TEMPLATES_ON_SERVER=true`): `POST /prompt?front=false` —
ComfyUI валидирует граф, **не ставя задание в очередь** и не тратя GPU.
```
модель допустима для эндпоинта <=> required(эндп) ⊆ токены(граф) ⊆ supplied(эндп)
эндпоинт supplied (что может подставить) required (обязательно в графе)
generations prompt, negative, seed, width, height prompt
edits prompt, negative, seed, image prompt, image
```
Отсюда: генерация не примет модель с `{image}` (не может подставить), а редактирование не
примет модель без `{image}` (картинка не должна остаться незамещённой).
### Плейсхолдеры в workflow
Точка внедрения значений находится **внутри workflow-графа** — в значениях полей узлов
пишутся токены вида `{имя}`. Никакой внешней карты узел↔поле нет; соответствие задаёт сам
граф. Фиксированный словарь токенов принадлежит адаптеру:
| Токен | Тип | Кто подставляет |
|---|---|---|
| `{prompt}` | строка | оба эндпоинта |
| `{negative}` | строка | всегда `""` (запрос негатив не принимает) |
| `{seed}` | число | оба эндпоинта |
| `{width}` / `{height}` | число | только генерация (из `size`) |
| `{image}` | строка | только edits (имя загруженного файла) |
Пример узла KSampler с плейсхолдерами:
```json
"83:3": { "class_type": "KSampler", "inputs": { "seed": "{seed}", "...": 0, "model": ["83:28", 0] } }
```
Правила подстановки:
- значение узла **равно ровно** `{seed}` -> подставляется **число** (типы сохраняются;
иначе `front=false` не примет строку в int-виджете); токен внутри текста -> строковая
вставка без искажения окружения;
- нераспознанный токен (вне словаря) -> workflow отклоняется на старте;
- незамещённый токен (эндпоинт не поставил значение) -> запрос отклоняется, ComfyUI не
получает задание.
### Проверка workflow
Двухуровневая, оба уровня на старте, для **каждой** модели реестра:
1. **Локально** (`app/templates.py`): обход `inputs` в поиске ссылок `[node_id, slot]` на
несуществующие узлы и проверка токенов (нет ли вне словаря). Найденное -> исключение со
списком `node X: input 'Y' links to missing node 'Z'`, адаптер не стартует.
2. **На сервере** (`VALIDATE_TEMPLATES_ON_SERVER=true`): граф каждой модели собирается
типизированными значениями-заглушками (dry-inject: `seed=1`, `width=height=1024`,
`prompt="x"`, `negative=""`, `image="dummy.png"`) и отправляется на
`POST /prompt?front=false` — ComfyUI валидирует граф, **не ставя задание в очередь** и
не тратя GPU. Заодно прогоняется сам инжектор. Ошибка логируется как диагностика старта
и не блокирует запуск (согласно прежнему поведению).
---
## Шаблоны workflow и их починка
## Workflow-файлы и их починка
Оба шаблона были выгружены из OpenWebUI (там прямая интеграция с ComfyUI). В
Оба workflow-файла были выгружены из OpenWebUI (там прямая интеграция с ComfyUI). В
**исходном виде они не проходили валидацию ComfyUI** — в цепочках моделей
отсутствовали узлы, на которые шли ссылки:
```
workflow_create.json workflow_edit.json
workflows/z_image_turbo.json workflows/qwen_image_edit.json
83:28 UNETLoader 115:37 UNETLoader
| на него никто не ссылается | на него никто не ссылается
@@ -359,12 +412,13 @@ workflow_create.json workflow_edit.json
Ошибка сервера: `prompt_outputs_failed_validation`, `KeyError: '83:43'` / `'115:117'`.
Исправления, применённые в репозиторных файлах (минимальные, по одной ссылке на шаблон):
Исправления, применённые в репозиторных файлах (минимальные, по одной ссылке на workflow;
в те же файлы добавлены плейсхолдеры `{prompt}`/`{seed}`/`{width}`/`{height}`/`{image}`/`{negative}`):
| Шаблон | Было | Стало |
| Workflow | Было | Стало |
|---|---|---|
| `workflow_create.json` | `83:3.inputs.model = ["83:43", 0]`, `83:27.inputs.clip = ["83:43", 1]` | `["83:28", 0]` (UNETLoader), `["83:30", 0]` (CLIPLoader) |
| `workflow_edit.json` | `115:66.inputs.model = ["115:117", 0]` | `["115:89", 0]` — LoRA-нода `115:89` подключена в цепочку (`115:37 -> 115:89 -> 115:66 -> 115:75 -> KSampler`) |
| `workflows/z_image_turbo.json` | `83:3.inputs.model = ["83:43", 0]`, `83:27.inputs.clip = ["83:43", 1]` | `["83:28", 0]` (UNETLoader), `["83:30", 0]` (CLIPLoader) |
| `workflows/qwen_image_edit.json` | `115:66.inputs.model = ["115:117", 0]` | `["115:89", 0]` — LoRA-нода `115:89` подключена в цепочку (`115:37 -> 115:89 -> 115:66 -> 115:75 -> KSampler`) |
Подключение LoRA во втором случае содержательно верно: в настройках OpenWebUI в поле
«Модель» редактирования указан именно Lightning-LoRA на 4 шага, а `steps: 4` в
@@ -376,7 +430,7 @@ KSampler без него не имеет смысла.
При смене workflow в OpenWebUI следует перезалить JSON сюда и сверить эти две
цепочки; локальная проверка на старте поймает проблему автоматически.
### Свойства шаблонов, которые стоит знать
### Свойства workflow, которые стоит знать
- **Размер выхода в edit ≠ `size`.** В графе за разрешение отвечает
`ImageScaleToTotalPixels` (`megapixels: 1`), а latent из `EmptySD3LatentImage`
@@ -396,10 +450,10 @@ KSampler без него не имеет смысла.
comfy-adapter/
├── app/
│ ├── main.py FastAPI: эндпоинты, цикл execute, разбор multipart/JSON
│ ├── config.py настройки из env + дефолтная карта маппинга
│ ├── config.py настройки из env + загрузка реестра моделей из models.json
│ ├── comfy_client.py async-клиент ComfyUI: /prompt, /upload/image, /history, /view
│ ├── injector.py deep-copy шаблона + подстановка значений, парсинг WxH, seed
│ ├── templates.py загрузка шаблонов, проверка битых ссылок и соответствия карте
│ ├── injector.py deep-copy workflow + замещение плейсхолдеров, парсинг WxH, seed
│ ├── templates.py загрузка реестра, проверка ссылок и токенов, выбор модели
│ ├── schemas.py pydantic-модели OpenAI-контракта
│ ├── errors.py иерархия исключений + обработчики в формат OpenAI
│ └── auth.py проверка статического Bearer-ключа
@@ -408,15 +462,16 @@ comfy-adapter/
│ ├── test_generations.py генерация: успех, инъекция значений, seed, все классы 400/502
│ ├── test_edits.py edits: multipart и base64, игнорирование size, ошибки входа
│ ├── test_comfy_client.py каждый метод клиента на respx-моках (успех/HTTP-ошибка/таймаут)
│ ├── test_injector.py маппинг, deep-copy без мутации шаблона, seed, парсинг size
│ ├── test_templates.py битые ссылки, сверка с картой, валидность репозиторных шаблонов
│ ├── test_config.py дефолты, переопределение из env, слияние MAPPING_FILE
│ ├── test_injector.py плейсхолдеры, deep-copy без мутации, seed, парсинг size
│ ├── test_templates.py битые ссылки, токены, совместимость и выбор модели, ошибки реестра
│ ├── test_config.py дефолты, переопределение из env, загрузка и fail-fast реестра
│ ├── test_auth.py ключ: отсутствует / неверный / верный; health без ключа
│ ├── test_errors.py канонический вид error-body, соответствие статусов
│ ├── test_models.py формат /v1/models
│ ├── test_registry_integration.py новая модель только правкой models.json (без кода)
│ └── test_live.py живые прогоны против реального ComfyUI (маркер live)
├── workflow_create.json исправленный шаблон генерации
├── workflow_edit.json исправленный шаблон редактирования
├── models.json реестр API-моделей: id -> {workflow, owned_by}
├── workflows/ workflow-файлы с плейсхолдерами (z_image_turbo.json, qwen_image_edit.json)
├── Dockerfile python:3.12-slim + uvicorn
├── pyproject.toml
└── .env.example
@@ -450,11 +505,14 @@ seed (повтор даёт идентичный base64), редактирова
## Ограничения
- **`n > 1` не поддерживается** -> 400. Нужен — правьте `batch_size` в шаблоне или
- **`n > 1` не поддерживается** -> 400. Нужен — правьте `batch_size` в workflow или
запускайте несколько запросов.
- **`size` в `/v1/images/edits` не влияет на выход** (см. «Свойства шаблонов»).
- **`negative_prompt` из запроса не принимается.** Узел в шаблоне есть, но поля в
схеме запроса нет — адаптер всегда пишет пустую строку.
- **`size` в `/v1/images/edits` не влияет на выход** (см. «Свойства workflow»): edit-граф не
содержит `{width}`/`{height}`, эндпоинт edits их не поставляет.
- **`negative_prompt` из запроса не принимается.** Поля в схеме запроса нет — токен
`{negative}`, если он есть в workflow, всегда резолвится в пустую строку `""`.
- **`model` не задан -> берётся первая совместимая с эндпоинтом модель** реестра (порядок
записей в `models.json` задаёт приоритет). Явный `default`-флаг пока не реализован.
- **Размер в генерации не квантуется.** Любое корректное `WxH` уходит в latent
напрямую; нестандартные значения могут дать артефакты или ошибку уже на стороне
ComfyUI (прилетит как 502, а не 400).
+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
from pathlib import Path
from pydantic import BaseModel, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
from .errors import ConfigError
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.
# Override the whole map via MAPPING_FILE (JSON with "create" and "edit" keys).
DEFAULT_MAPPING: dict[str, dict[str, dict[str, str]]] = {
"create": {
"prompt": {"node": "83:27", "field": "text"},
"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 ModelEntry(BaseModel):
"""A registry record: an API model id maps to one workflow file."""
workflow: Path
owned_by: str = "comfy-adapter"
class Settings(BaseSettings):
@@ -34,21 +24,31 @@ class Settings(BaseSettings):
adapter_api_key: str = ""
execution_timeout: float = 300.0
poll_interval: float = 1.5
create_template_path: Path = REPO_ROOT / "workflow_create.json"
edit_template_path: Path = REPO_ROOT / "workflow_edit.json"
mapping_file: Path | None = None
# Validate graphs on the ComfyUI server (POST /prompt?front=false) at startup.
# JSON file mapping an API model id to its workflow. Required; no defaults in code.
models_file: Path = REPO_ROOT / "models.json"
# Validate collected graphs on the ComfyUI server (POST /prompt?front=false) at startup.
validate_templates_on_server: bool = True
def mapping(self) -> dict[str, dict[str, dict[str, str]]]:
if self.mapping_file is None:
return DEFAULT_MAPPING
with open(self.mapping_file, encoding="utf-8") as f:
loaded = json.load(f)
merged = {k: dict(v) for k, v in DEFAULT_MAPPING.items()}
for section, section_map in loaded.items():
merged.setdefault(section, {}).update(section_map)
return merged
def load_registry(self) -> dict[str, ModelEntry]:
"""Load the model registry, preserving declaration order (first = preferred).
Fail-fast: a missing or malformed file raises ConfigError and blocks startup.
"""
try:
raw = json.loads(self.models_file.read_text(encoding="utf-8"))
except FileNotFoundError as e:
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:
+7
View File
@@ -31,6 +31,13 @@ class ComfyUIError(AdapterError):
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:
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 re
import secrets
from typing import Any
@@ -7,6 +14,21 @@ from .errors import InvalidRequest
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]:
"""Parse OpenAI `WxH` size strings (e.g. '1024x1024')."""
@@ -28,40 +50,55 @@ def new_seed(seed: int | None) -> int:
return seed
def build_workflow(
template: dict[str, Any],
mapping: dict[str, dict[str, str]],
*,
prompt: str,
seed: int,
size: str | None = None,
image_name: str | None = None,
negative: str = "",
) -> dict[str, Any]:
"""Deep-copy the template and inject request values per the node mapping."""
def extract_tokens(graph: dict[str, Any]) -> tuple[set[str], list[str]]:
"""Token names referenced by the graph.
Returns (known_tokens, unknown_token_errors). A token is any `{name}` inside
a string node input. Unknown names are reported so startup can reject the
workflow before it ever reaches a request.
"""
declared: set[str] = set()
unknown: set[str] = set()
for node_id, node in graph.items():
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)
def put(key: str, value: Any) -> None:
spec = mapping.get(key)
if spec is None:
return
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
for node in graph.values():
inputs = node.get("inputs", {})
for field, value in inputs.items():
if isinstance(value, str):
inputs[field] = _substitute(value, values)
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)
+31 -26
View File
@@ -8,9 +8,9 @@ from fastapi import FastAPI, Request
from .auth import require_key
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 .injector import build_workflow, new_seed
from .injector import build_workflow, new_seed, parse_size
from .schemas import (
ImageEditJsonRequest,
ImageGenerationRequest,
@@ -19,32 +19,42 @@ from .schemas import (
ModelCard,
ModelsResponse,
)
from .templates import load_all
from .templates import load_registry, select_model
logger = logging.getLogger("comfy-adapter")
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
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:
settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
create_graph, edit_graph = load_all(settings)
registry = load_registry(settings)
client = ComfyClient(settings.comfy_base_url)
if settings.validate_templates_on_server:
for model in registry.values():
try:
await client.validate_graph(create_graph)
await client.validate_graph(edit_graph)
logger.info("workflow templates validated on ComfyUI server")
await client.validate_graph(build_workflow(model.graph, _DUMMY_VALUES))
logger.info("workflow validated on ComfyUI server: %s", model.id)
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.comfy = client
app.state.templates = {"create": create_graph, "edit": edit_graph}
logger.info("comfy-adapter ready (server=%s)", settings.comfy_base_url)
app.state.registry = registry
logger.info("comfy-adapter ready (server=%s, models=%s)", settings.comfy_base_url, list(registry))
try:
yield
finally:
@@ -75,7 +85,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/v1/models")
async def models(request: Request) -> ModelsResponse:
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")
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")
if body.n not in (None, 1):
raise InvalidRequest("only n=1 is supported")
if body.model is not None and body.model != CREATE_MODEL:
raise InvalidRequest(f"unknown model '{body.model}', expected '{CREATE_MODEL}'")
model = select_model(request.app.state.registry, body.model, "generations")
width, height = parse_size(body.size)
graph = build_workflow(
app.state.templates["create"],
settings.mapping()["create"],
prompt=body.prompt,
seed=new_seed(body.seed),
size=body.size,
model.graph,
{"prompt": body.prompt, "seed": new_seed(body.seed), "negative": "", "width": width, "height": height},
)
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")
if not prompt.strip():
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)
model = select_model(request.app.state.registry, model, "edits")
comfy: ComfyClient = app.state.comfy
name = await comfy.upload_image(image_bytes)
graph = build_workflow(
app.state.templates["edit"],
settings.mapping()["edit"],
prompt=prompt,
seed=new_seed(seed),
image_name=name,
negative="",
model.graph,
{"prompt": prompt, "seed": new_seed(seed), "negative": "", "image": name},
)
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
from dataclasses import dataclass, field
from pathlib import Path
from .config import Settings
from .errors import AdapterError
from .errors import AdapterError, ConfigError, InvalidRequest
from .injector import extract_tokens
class TemplateError(AdapterError):
@@ -13,6 +21,29 @@ class TemplateError(AdapterError):
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]:
"""References to node ids that do not exist in the graph."""
errors = []
@@ -24,39 +55,58 @@ def find_broken_links(graph: dict) -> list[str]:
return errors
def check_mapping(graph: dict, mapping: dict, name: str) -> list[str]:
"""Every mapped node id must exist with a scalar input field."""
errors = []
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 is_compatible(declared: frozenset[str], endpoint: str) -> bool:
vocab = ENDPOINT_VOCAB[endpoint]
return vocab["required"] <= declared and declared <= vocab["supplied"]
def _mapped_fields(spec: dict) -> list[str]:
if "width_field" in spec:
return [spec["width_field"], spec["height_field"]]
return [spec["field"]]
def select_model(models: dict[str, Model], model: str | None, endpoint: str) -> Model:
"""Pick the model for a request by id, or the first compatible one.
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:
try:
with open(path, encoding="utf-8") as f:
graph = json.load(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:
raise TemplateError(f"workflow template '{graph_name}' ({path}) is invalid:\n " + "\n ".join(errors))
return graph
def load_all(settings: Settings) -> tuple[dict, dict]:
mapping = settings.mapping()
create = load_template(
settings.create_template_path, "workflow_create.json", mapping, "create"
raise TemplateError(
f"workflow for model '{model_id}' ({workflow_path}) is invalid:\n " + "\n ".join(errors)
)
edit = load_template(settings.edit_template_path, "workflow_edit.json", mapping, "edit")
return create, edit
return Model(id=model_id, graph=graph, declared=frozenset(declared), owned_by=owned_by, workflow=workflow_path)
def load_registry(settings: Settings) -> dict[str, Model]:
"""Load every model in the registry, preserving order for default selection."""
base_dir = settings.models_file.parent
registry = settings.load_registry()
models: dict[str, Model] = {}
for model_id, entry in registry.items():
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():
@@ -6,7 +9,7 @@ def test_defaults():
assert s.comfy_base_url == "http://192.168.0.3:8188"
assert s.execution_timeout == 300.0
assert s.poll_interval == 1.5
assert s.mapping() == DEFAULT_MAPPING
assert s.models_file.name == "models.json"
def test_env_override(monkeypatch):
@@ -21,12 +24,40 @@ def test_env_override(monkeypatch):
assert s.poll_interval == 0.5
def test_mapping_file_override(tmp_path):
f = tmp_path / "mapping.json"
f.write_text('{"create": {"prompt": {"node": "1", "field": "t"}}}', encoding="utf-8")
s = Settings(_env_file=None, mapping_file=f)
m = s.mapping()
assert m["create"]["prompt"] == {"node": "1", "field": "t"}
# other sections/keys keep defaults
assert m["edit"] == DEFAULT_MAPPING["edit"]
assert m["create"]["seed"] == DEFAULT_MAPPING["create"]["seed"]
def test_registry_loads_shipped_in_order():
s = Settings(_env_file=None)
registry = s.load_registry()
# order preserved (first = preferred for default selection)
assert list(registry) == ["z-image-turbo", "qwen-image-edit"]
assert registry["z-image-turbo"].workflow.name == "z_image_turbo.json"
assert registry["z-image-turbo"].owned_by == "comfy-adapter"
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"}))
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):
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
from app.config import DEFAULT_MAPPING
from app.config import Settings
from app.errors import InvalidRequest
from app.injector import build_workflow, new_seed, parse_size
from app.templates import load_all
from app.config import Settings
from app.templates import load_registry
@pytest.fixture(scope="module")
def templates():
return load_all(Settings(_env_file=None))
def registry():
return load_registry(Settings(_env_file=None))
def test_parse_size():
@@ -33,41 +32,57 @@ def test_seed_fixed_and_range():
new_seed(-1)
def test_create_injection(templates):
create, _ = templates
mapping = DEFAULT_MAPPING["create"]
graph = build_workflow(create, mapping, prompt="a cat", seed=77, size="832x1216")
def test_create_injection_and_template_untouched(registry):
create = registry["z-image-turbo"].graph
graph = build_workflow(create, {"prompt": "a cat", "seed": 77, "negative": "", "width": 832, "height": 1216})
assert graph["83:27"]["inputs"]["text"] == "a cat"
assert graph["83:13"]["inputs"]["width"] == 832
assert graph["83:13"]["inputs"]["height"] == 1216
assert graph["83:3"]["inputs"]["seed"] == 77
# template untouched
assert create["83:27"]["inputs"]["text"] == "\n"
assert create["83:13"]["inputs"]["width"] == 1280
# template still holds placeholders (deep copy, no mutation)
assert create["83:27"]["inputs"]["text"] == "{prompt}"
assert create["83:13"]["inputs"]["width"] == "{width}"
assert create["83:3"]["inputs"]["seed"] == "{seed}"
def test_edit_injection(templates):
_, edit = templates
mapping = DEFAULT_MAPPING["edit"]
graph = build_workflow(edit, mapping, prompt="make it night", seed=5, image_name="adapter_x.png")
def test_injection_is_typed(registry):
graph = build_workflow(registry["z-image-turbo"].graph, {"prompt": "x", "seed": 7, "negative": "", "width": 64, "height": 128})
assert isinstance(graph["83:3"]["inputs"]["seed"], int) # numeric token -> int, not "{seed}"
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:110"]["inputs"]["prompt"] == ""
assert graph["78"]["inputs"]["image"] == "adapter_x.png"
assert graph["115:3"]["inputs"]["seed"] == 5
def test_edit_seed_varies_per_call(templates):
_, edit = templates
mapping = DEFAULT_MAPPING["edit"]
def test_edit_seed_varies_per_call(registry):
edit = registry["qwen-image-edit"].graph
seeds = set()
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"])
assert len(seeds) == 3
def test_unknown_mapping_node_raises(templates):
create, _ = templates
bad_mapping = {"prompt": {"node": "999", "field": "text"}}
with pytest.raises(InvalidRequest, match="999"):
build_workflow(create, bad_mapping, prompt="p", seed=1)
def test_unrecognized_token_rejected():
graph = {"1": {"class_type": "X", "inputs": {"text": "{foo}"}}}
with pytest.raises(InvalidRequest, match="unrecognized token"):
build_workflow(graph, {"prompt": "p"})
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
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():
@@ -22,36 +30,84 @@ def test_find_broken_links_ok_on_valid_graph():
assert find_broken_links(graph) == []
def test_check_mapping_missing_node():
graph = {"1": {"class_type": "X", "inputs": {"text": ""}}}
mapping = {"create": {"prompt": {"node": "404", "field": "text"}}}
errors = check_mapping(graph, mapping["create"], "create")
assert any("not found" in e for e in errors)
def test_extract_tokens_reports_known_and_unknown():
graph = {
"1": {"class_type": "X", "inputs": {"text": "{prompt}", "seed": "{seed}"}},
"2": {"class_type": "Y", "inputs": {"note": "static {notatoken} text"}},
}
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():
graph = {"1": {"class_type": "X", "inputs": {"text": ["2", 0]}}}
mapping = {"create": {"prompt": {"node": "1", "field": "text"}}}
errors = check_mapping(graph, mapping["create"], "create")
assert any("no scalar input" in e for e in errors)
def test_shipped_registry_loads_with_expected_tokens():
registry = load_registry(Settings(_env_file=None))
assert set(registry) == {"z-image-turbo", "qwen-image-edit"}
assert registry["z-image-turbo"].declared == frozenset({"prompt", "seed", "width", "height"})
assert registry["qwen-image-edit"].declared == frozenset({"prompt", "negative", "seed", "image"})
def test_shipped_templates_load_cleanly():
s = Settings(_env_file=None)
create, edit = load_all(s)
assert create["83:28"]["class_type"] == "UNETLoader"
assert edit["115:89"]["class_type"] == "LoraLoaderModelOnly"
def test_generations_graph_incompatible_with_edits():
assert not is_compatible(frozenset({"prompt", "seed", "width", "height"}), "edits")
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.write_text('{"1": {"class_type": "X", "inputs": {"m": ["9", 0]}}}', encoding="utf-8")
good = tmp_path / "good.json"
good.write_text(
'{"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)
models = tmp_path / "models.json"
models.write_text('{"m1": {"workflow": "bad.json"}}', encoding="utf-8")
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)
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" }
},
"78": {
"inputs": { "image": "" },
"inputs": { "image": "{image}" },
"class_type": "LoadImage",
"_meta": { "title": "Load Image" }
},
@@ -44,7 +44,7 @@
},
"115:110": {
"inputs": {
"prompt": "",
"prompt": "{negative}",
"clip": ["115:38", 0],
"vae": ["115:39", 0],
"image1": ["115:93", 0]
@@ -62,7 +62,7 @@
},
"115:111": {
"inputs": {
"prompt": "",
"prompt": "{prompt}",
"clip": ["115:38", 0],
"vae": ["115:39", 0],
"image1": ["115:93", 0]
@@ -99,7 +99,7 @@
},
"115:3": {
"inputs": {
"seed": 1118877715456453,
"seed": "{seed}",
"steps": 4,
"cfg": 1,
"sampler_name": "euler",
@@ -23,8 +23,8 @@
},
"83:13": {
"inputs": {
"width": 1280,
"height": 1280,
"width": "{width}",
"height": "{height}",
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
@@ -45,7 +45,7 @@
},
"83:3": {
"inputs": {
"seed": 578351132403975,
"seed": "{seed}",
"steps": 9,
"cfg": 1,
"sampler_name": "res_multistep",
@@ -61,7 +61,7 @@
},
"83:27": {
"inputs": {
"text": "\n",
"text": "{prompt}",
"clip": ["83:30", 0]
},
"class_type": "CLIPTextEncode",