Files
comfy-adapter/app/templates.py
T

113 lines
4.2 KiB
Python

"""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, ConfigError, InvalidRequest
from .injector import extract_tokens
class TemplateError(AdapterError):
"""Malformed workflow template; blocks startup."""
def __init__(self, message: str):
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 = []
for node_id, node in graph.items():
for field, value in node.get("inputs", {}).items():
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str):
if value[0] not in graph:
errors.append(f"node {node_id}: input '{field}' links to missing node '{value[0]}'")
return errors
def is_compatible(declared: frozenset[str], endpoint: str) -> bool:
vocab = ENDPOINT_VOCAB[endpoint]
return vocab["required"] <= declared and declared <= vocab["supplied"]
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 _read_graph(path: Path, model_id: str) -> dict:
try:
with open(path, encoding="utf-8") as f:
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 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_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