mirror of
https://github.com/Haidra-Org/AI-Horde.git
synced 2026-11-06 05:17:31 +00:00
feat: additional annotation control types and gating
- Introduces unified ControlNet/annotation type constants and extends API models/parsers to support `annotation` forms, `annotation_types`, and `allow_extended_controlnet`. - Dispatch logic now gates extended control types by bridge capability/version and worker opt-in, while preserving legacy `hough` behavior. - Alchemy annotation requests now validate `payload.control_type`, return image-output handling via R2, and use detector-class kudos buckets for pricing. - Worker persistence/details were extended for advertised annotation types, - Unit/integration coverage for relevant behavior was added, including a cross-repo SDK wire-contract test for request/response compatibility. fix: include sql migration commands tests: reconcile annotator tests with torch->numpy change
This commit is contained in:
1 parent
fbd5ed465e
commit
f57a799de9
21 files changed
+1410
-25
No files matched your search
@@ -7,7 +7,7 @@ import copy
|
||||
from flask_restx import fields
|
||||
|
||||
from horde.apis.models import v2
|
||||
from horde.consts import KNOWN_POST_PROCESSORS, KNOWN_SAMPLERS, KNOWN_WORKFLOWS
|
||||
from horde.consts import IMAGE_CONTROL_TYPES, KNOWN_CONTROL_TYPES, KNOWN_POST_PROCESSORS, KNOWN_SAMPLERS, KNOWN_WORKFLOWS
|
||||
from horde.vars import horde_title
|
||||
|
||||
|
||||
@@ -138,6 +138,14 @@ class ImageParsers(v2.Parsers):
|
||||
help="If True, this worker will pick up requests requesting ControlNet.",
|
||||
location="json",
|
||||
)
|
||||
self.job_pop_parser.add_argument(
|
||||
"allow_extended_controlnet",
|
||||
type=bool,
|
||||
required=False,
|
||||
default=False,
|
||||
help="If True, this worker will pick up requests requesting extended ControlNet control types.",
|
||||
location="json",
|
||||
)
|
||||
self.job_pop_parser.add_argument(
|
||||
"allow_sdxl_controlnet",
|
||||
type=bool,
|
||||
@@ -416,17 +424,7 @@ class ImageModels(v2.Models):
|
||||
),
|
||||
"control_type": fields.String(
|
||||
required=False,
|
||||
enum=[
|
||||
"canny",
|
||||
"hed",
|
||||
"depth",
|
||||
"normal",
|
||||
"openpose",
|
||||
"seg",
|
||||
"scribble",
|
||||
"fakescribbles",
|
||||
"hough",
|
||||
],
|
||||
enum=IMAGE_CONTROL_TYPES,
|
||||
),
|
||||
"image_is_control": fields.Boolean(
|
||||
default=False,
|
||||
@@ -566,6 +564,10 @@ class ImageModels(v2.Models):
|
||||
default=True,
|
||||
description="If True, this worker will pick up requests requesting ControlNet.",
|
||||
),
|
||||
"allow_extended_controlnet": fields.Boolean(
|
||||
default=False,
|
||||
description="If True, this worker will pick up requests requesting extended ControlNet control types.",
|
||||
),
|
||||
"allow_sdxl_controlnet": fields.Boolean(
|
||||
default=True,
|
||||
description="If True, this worker will pick up requests requesting SDXL ControlNet.",
|
||||
@@ -745,14 +747,30 @@ class ImageModels(v2.Models):
|
||||
# Intentionally left blank to allow to add payloads later
|
||||
self.input_model_interrogation_form_payload = api.model(
|
||||
"ModelInterrogationFormPayloadStable",
|
||||
{"*": fields.Wildcard(fields.String)},
|
||||
{
|
||||
"control_type": fields.String(
|
||||
required=False,
|
||||
enum=KNOWN_CONTROL_TYPES,
|
||||
description="For the 'annotation' form, the controlnet control map to produce.",
|
||||
),
|
||||
"*": fields.Wildcard(fields.String),
|
||||
},
|
||||
)
|
||||
self.input_model_interrogation_form = api.model(
|
||||
"ModelInterrogationFormStable",
|
||||
{
|
||||
"name": fields.String(
|
||||
required=True,
|
||||
enum=["caption", "interrogation", "nsfw", "vectorize", "palette", "describe", "aesthetic"]
|
||||
enum=[
|
||||
"caption",
|
||||
"interrogation",
|
||||
"nsfw",
|
||||
"vectorize",
|
||||
"palette",
|
||||
"describe",
|
||||
"aesthetic",
|
||||
"annotation",
|
||||
]
|
||||
+ list(KNOWN_POST_PROCESSORS.keys()),
|
||||
description="The type of interrogation this is.",
|
||||
unique=True,
|
||||
@@ -832,11 +850,28 @@ class ImageModels(v2.Models):
|
||||
"forms": fields.List(
|
||||
fields.String(
|
||||
description="The type of interrogation this worker can fulfil.",
|
||||
enum=["caption", "interrogation", "nsfw", "vectorize", "palette", "describe", "aesthetic"]
|
||||
enum=[
|
||||
"caption",
|
||||
"interrogation",
|
||||
"nsfw",
|
||||
"vectorize",
|
||||
"palette",
|
||||
"describe",
|
||||
"aesthetic",
|
||||
"annotation",
|
||||
]
|
||||
+ list(KNOWN_POST_PROCESSORS.keys()),
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
"annotation_types": fields.List(
|
||||
fields.String(
|
||||
description="The annotation control types this worker can fulfil.",
|
||||
enum=KNOWN_CONTROL_TYPES,
|
||||
),
|
||||
required=False,
|
||||
unique=True,
|
||||
),
|
||||
"amount": fields.Integer(
|
||||
default=1,
|
||||
description="The amount of forms to pop at the same time.",
|
||||
@@ -872,7 +907,16 @@ class ImageModels(v2.Models):
|
||||
),
|
||||
"form": fields.String(
|
||||
description="The name of this interrogation form",
|
||||
enum=["caption", "interrogation", "nsfw", "vectorize", "palette", "describe", "aesthetic"]
|
||||
enum=[
|
||||
"caption",
|
||||
"interrogation",
|
||||
"nsfw",
|
||||
"vectorize",
|
||||
"palette",
|
||||
"describe",
|
||||
"aesthetic",
|
||||
"annotation",
|
||||
]
|
||||
+ list(KNOWN_POST_PROCESSORS.keys()),
|
||||
),
|
||||
"payload": fields.Nested(self.input_model_interrogation_form_payload, skip_none=True),
|
||||
|
||||
@@ -866,6 +866,9 @@ class Models:
|
||||
),
|
||||
"models": fields.List(fields.String(description="Which models this worker if offering.")),
|
||||
"forms": fields.List(fields.String(description="Which forms this worker if offering.")),
|
||||
"annotation_types": fields.List(
|
||||
fields.String(description="Which annotation control types this worker is offering."),
|
||||
),
|
||||
"team": fields.Nested(
|
||||
self.response_model_team_details_lite,
|
||||
"The Team to which this worker is dedicated.",
|
||||
@@ -910,6 +913,10 @@ class Models:
|
||||
default=None,
|
||||
description="If True, this worker supports and allows controlnet requests.",
|
||||
),
|
||||
"extended_controlnet": fields.Boolean(
|
||||
default=None,
|
||||
description="If True, this worker supports and allows extended controlnet control types.",
|
||||
),
|
||||
"sdxl_controlnet": fields.Boolean(
|
||||
default=None,
|
||||
description="If True, this worker supports and allows SDXL controlnet requests.",
|
||||
|
||||
@@ -35,6 +35,7 @@ from horde.classes.stable.interrogation import Interrogation
|
||||
from horde.classes.stable.interrogation_worker import InterrogationWorker
|
||||
from horde.classes.stable.waiting_prompt import ImageWaitingPrompt
|
||||
from horde.classes.stable.worker import ImageWorker
|
||||
from horde.consts import KNOWN_CONTROL_TYPES
|
||||
from horde.countermeasures import CounterMeasures
|
||||
from horde.database import functions as database
|
||||
from horde.database.kudos_reservations import reserve_kudos
|
||||
@@ -710,6 +711,7 @@ class ImageJobPop(JobPopTemplate):
|
||||
allow_unsafe_ipaddr=self.args.allow_unsafe_ipaddr,
|
||||
allow_post_processing=self.args.allow_post_processing,
|
||||
allow_controlnet=self.args.allow_controlnet,
|
||||
allow_extended_controlnet=self.args.allow_extended_controlnet,
|
||||
allow_sdxl_controlnet=self.args.allow_sdxl_controlnet,
|
||||
allow_lora=self.args.allow_lora,
|
||||
extra_slow_worker=self.args.extra_slow_worker,
|
||||
@@ -1028,6 +1030,15 @@ class Interrogate(Resource):
|
||||
def validate(self):
|
||||
if settings.mode_maintenance():
|
||||
raise e.MaintenanceMode("Interrogate")
|
||||
for form in self.forms:
|
||||
if form.get("name") != "annotation":
|
||||
continue
|
||||
control_type = (form.get("payload") or {}).get("control_type")
|
||||
if control_type not in KNOWN_CONTROL_TYPES:
|
||||
raise e.BadRequest(
|
||||
f"The 'annotation' form requires a payload.control_type from {KNOWN_CONTROL_TYPES}.",
|
||||
rc="InvalidControlType",
|
||||
)
|
||||
if self.args.webhook and not self.args.webhook.startswith("https://"):
|
||||
raise e.BadRequest("webhooks need to point to an https endpoint.")
|
||||
with get_app().app_context():
|
||||
@@ -1189,6 +1200,14 @@ class InterrogatePop(JobPopTemplate):
|
||||
help="The maximum amount of 512x512 tiles this worker can post-process",
|
||||
location="json",
|
||||
)
|
||||
post_parser.add_argument(
|
||||
"annotation_types",
|
||||
type=list,
|
||||
required=False,
|
||||
default=None,
|
||||
help="The annotation control types this worker can fulfil",
|
||||
location="json",
|
||||
)
|
||||
|
||||
decorators = [limiter.limit("60/second")]
|
||||
|
||||
@@ -1223,6 +1242,7 @@ class InterrogatePop(JobPopTemplate):
|
||||
self.forms = []
|
||||
if self.args.forms:
|
||||
self.forms = self.args.forms
|
||||
self.annotation_types = set(self.args.annotation_types or [])
|
||||
self.worker_ip = request.remote_addr
|
||||
self.validate()
|
||||
self.check_in()
|
||||
@@ -1247,6 +1267,8 @@ class InterrogatePop(JobPopTemplate):
|
||||
priority_user_ids=self.priority_user_ids,
|
||||
)
|
||||
for form in priority_list:
|
||||
if not self._supports_annotation_type(form):
|
||||
continue
|
||||
# We append to the list so that we have the prioritized forms first
|
||||
self.prioritized_forms.append(form)
|
||||
|
||||
@@ -1257,6 +1279,8 @@ class InterrogatePop(JobPopTemplate):
|
||||
forms_list=self.forms,
|
||||
excluded_forms=self.prioritized_forms,
|
||||
):
|
||||
if not self._supports_annotation_type(form):
|
||||
continue
|
||||
self.prioritized_forms.append(form)
|
||||
# logger.warning(datetime.utcnow())
|
||||
worker_ret = {"forms": []}
|
||||
@@ -1299,10 +1323,20 @@ class InterrogatePop(JobPopTemplate):
|
||||
# logger.warning(datetime.utcnow())
|
||||
return ({"skipped": self.skipped}, 200)
|
||||
|
||||
def _supports_annotation_type(self, form):
|
||||
"""Return whether this worker advertises the annotation type requested by ``form``."""
|
||||
if form.name != "annotation":
|
||||
return True
|
||||
if not self.annotation_types:
|
||||
return False
|
||||
payload = form.payload or {}
|
||||
return payload.get("control_type") in self.annotation_types
|
||||
|
||||
def check_in(self):
|
||||
self.worker.check_in(
|
||||
max_tiles=self.args.max_tiles,
|
||||
forms=self.forms,
|
||||
annotation_types=self.annotation_types,
|
||||
safe_ip=self.safe_ip,
|
||||
ipaddr=self.worker_ip,
|
||||
threads=self.args.threads,
|
||||
|
||||
@@ -9,8 +9,13 @@ import semver
|
||||
from horde.consts import KNOWN_POST_PROCESSORS
|
||||
from horde.logger import logger
|
||||
|
||||
# Bridge version of "AI Horde Worker reGen" from which the image-utilities backend can annotate the
|
||||
# extended controlnet control types (everything outside LEGACY_IMAGE_CONTROL_TYPES).
|
||||
EXTENDED_CONTROLNET_REGEN_VERSION = 17
|
||||
|
||||
BRIDGE_CAPABILITIES = {
|
||||
"AI Horde Worker reGen": {
|
||||
EXTENDED_CONTROLNET_REGEN_VERSION: {"extended_controlnet"},
|
||||
13: {
|
||||
"4xNomos8kSC",
|
||||
"4xLSDIRplus",
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy import JSON, Enum
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
from horde.classes.base.kudos import kudos_event
|
||||
from horde.consts import KNOWN_POST_PROCESSORS
|
||||
from horde.consts import IMAGE_RESULT_ALCHEMY_FORMS, annotation_detector_kudos_bucket
|
||||
from horde.database.kudos_reservations import release_reservation, reserve_kudos
|
||||
from horde.enums import State
|
||||
from horde.flask import SQLITE_MODE, db
|
||||
@@ -92,9 +92,9 @@ class InterrogationForms(db.Model):
|
||||
"payload": self.payload,
|
||||
"source_image": self.interrogation.source_image,
|
||||
}
|
||||
if self.name in KNOWN_POST_PROCESSORS:
|
||||
if self.name in IMAGE_RESULT_ALCHEMY_FORMS:
|
||||
ret_dict["r2_upload"] = generate_procgen_upload_url(str(self.id), False)
|
||||
logger.debug([self.name in KNOWN_POST_PROCESSORS, self.name, KNOWN_POST_PROCESSORS])
|
||||
logger.debug([self.name in IMAGE_RESULT_ALCHEMY_FORMS, self.name, IMAGE_RESULT_ALCHEMY_FORMS])
|
||||
logger.debug(ret_dict)
|
||||
return ret_dict
|
||||
|
||||
@@ -110,7 +110,7 @@ class InterrogationForms(db.Model):
|
||||
if self.result[form_name] == "R2":
|
||||
self.result[form_name] = generate_procgen_download_url(str(self.id), False)
|
||||
if not self.interrogation.r2stored:
|
||||
if self.name in KNOWN_POST_PROCESSORS:
|
||||
if self.name in IMAGE_RESULT_ALCHEMY_FORMS:
|
||||
# Post-processed images live in R2 only for 120 minutes
|
||||
hr.horde_r_setex(
|
||||
f"{self.name}_{self.interrogation.source_image}",
|
||||
@@ -310,7 +310,15 @@ class Interrogation(db.Model):
|
||||
# Interrogations are more intensive so they reward better
|
||||
if form["name"] == "interrogation":
|
||||
kudos = 3
|
||||
elif form["name"] in KNOWN_POST_PROCESSORS:
|
||||
elif form["name"] == "annotation":
|
||||
# The annotation form scales with the source-image size and its detector cost class:
|
||||
# a control map from a heavy transformers-hub detector earns more per tile than one
|
||||
# from a weightless OpenCV detector.
|
||||
control_type = (form.get("payload") or {}).get("control_type")
|
||||
kudos = self.image_tiles * annotation_detector_kudos_bucket(control_type)
|
||||
elif form["name"] in IMAGE_RESULT_ALCHEMY_FORMS:
|
||||
# Post-processors (upscalers, face restorers, strip_background) scale with the
|
||||
# source-image size, mirroring the upscaler/facefixer pricing.
|
||||
logger.debug(self.image_tiles)
|
||||
kudos = self.image_tiles * 3
|
||||
form_entry = InterrogationForms(
|
||||
|
||||
@@ -33,12 +33,25 @@ class WorkerInterrogationForm(db.Model):
|
||||
form = db.Column(db.String(30))
|
||||
|
||||
|
||||
class WorkerAnnotationType(db.Model):
|
||||
__tablename__ = "interrogation_worker_annotation_types"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
worker_id = db.Column(
|
||||
uuid_column_type(),
|
||||
db.ForeignKey("workers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
worker = db.relationship("InterrogationWorker", back_populates="annotation_types")
|
||||
annotation_type = db.Column(db.String(64))
|
||||
|
||||
|
||||
class InterrogationWorker(WorkerTemplate):
|
||||
__mapper_args__ = {
|
||||
"polymorphic_identity": "interrogation_worker",
|
||||
}
|
||||
|
||||
forms = db.relationship("WorkerInterrogationForm", back_populates="worker")
|
||||
annotation_types = db.relationship("WorkerAnnotationType", back_populates="worker")
|
||||
processing_forms = db.relationship("InterrogationForms", back_populates="worker")
|
||||
wtype = "interrogation"
|
||||
|
||||
@@ -49,6 +62,9 @@ class InterrogationWorker(WorkerTemplate):
|
||||
# If's OK to provide an empty list here as we don't actually modify this var
|
||||
# We only check it in can_generate
|
||||
self.set_forms(kwargs.get("forms"))
|
||||
# Advertised annotation types are display/moderation only; pop-time matching stays on the
|
||||
# live pop payload. A legacy alchemist sends no annotation_types, which clears cleanly.
|
||||
self.set_annotation_types(kwargs.get("annotation_types"))
|
||||
form_names = self.get_form_names()
|
||||
if len(form_names) == 0:
|
||||
self.set_forms(["caption"])
|
||||
@@ -139,6 +155,30 @@ class InterrogationWorker(WorkerTemplate):
|
||||
db.session.add(form)
|
||||
db.session.commit()
|
||||
|
||||
def get_annotation_type_names(self):
|
||||
annotation_type_names = (
|
||||
db.session.query(func.distinct(WorkerAnnotationType.annotation_type).label("name"))
|
||||
.filter(WorkerAnnotationType.worker_id == self.id)
|
||||
.all()
|
||||
)
|
||||
return [a.name for a in annotation_type_names]
|
||||
|
||||
def set_annotation_types(self, annotation_types):
|
||||
# A legacy alchemist advertises no annotation types; treat absent as an empty set.
|
||||
if not annotation_types:
|
||||
annotation_types = []
|
||||
# We don't allow workers to claim they can serve more than 100 annotation types (to prevent abuse)
|
||||
annotation_types = list(annotation_types)[:100]
|
||||
existing_annotation_types = db.session.query(WorkerAnnotationType).filter_by(worker_id=self.id)
|
||||
existing_annotation_type_names = set([a.annotation_type for a in existing_annotation_types.all()])
|
||||
if existing_annotation_type_names == set(annotation_types):
|
||||
return
|
||||
existing_annotation_types.delete()
|
||||
for annotation_type in annotation_types:
|
||||
entry = WorkerAnnotationType(worker_id=self.id, annotation_type=annotation_type)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
def get_performance(self):
|
||||
performances = [p.performance for p in self.performance]
|
||||
if len(performances):
|
||||
@@ -150,4 +190,5 @@ class InterrogationWorker(WorkerTemplate):
|
||||
def get_details(self, details_privilege=0):
|
||||
ret_dict = super().get_details(details_privilege)
|
||||
ret_dict["forms"] = self.get_form_names()
|
||||
ret_dict["annotation_types"] = self.get_annotation_type_names()
|
||||
return ret_dict
|
||||
@@ -8,6 +8,49 @@ import sys
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
# The kudos model is a frozen artifact whose control_type one-hot slots are the classic types only.
|
||||
# New unified control types are collapsed onto their closest classic cost-class before the one-hot
|
||||
# lookup, so the input vector keeps its trained length. This mirrors hordelib's checkpoint-reuse map:
|
||||
# cheap algorithmic preprocessors land on `canny`, the lineart family lands on `hed`, and `mlsd`
|
||||
# uses the legacy `hough` slot. Types absent from this map (and already classic) pass through
|
||||
# unchanged; anything still unknown to KNOWN_CONTROL_TYPES falls back to the neutral "None" slot.
|
||||
CANONICAL_KUDOS_CONTROL_TYPES = {
|
||||
"binary": "scribble",
|
||||
"scribble_xdog": "scribble",
|
||||
"scribble_pidinet": "scribble",
|
||||
"pidinet": "hed",
|
||||
"teed": "hed",
|
||||
"standard_lineart": "hed",
|
||||
"lineart": "hed",
|
||||
"lineart_anime": "hed",
|
||||
"lineart_anime_denoise": "hed",
|
||||
"pyracanny": "canny",
|
||||
"color": "canny",
|
||||
"shuffle": "canny",
|
||||
"recolor_luminance": "canny",
|
||||
"recolor_intensity": "canny",
|
||||
"tile": "canny",
|
||||
"tile_ttplanet_guided": "canny",
|
||||
"tile_ttplanet_simple": "canny",
|
||||
"midas_depth": "depth",
|
||||
"zoe_depth": "depth",
|
||||
"depth_anything": "depth",
|
||||
"depth_anything_v2": "depth",
|
||||
"normal_bae": "normal",
|
||||
"oneformer_ade20k": "seg",
|
||||
"oneformer_coco": "seg",
|
||||
"mlsd": "hough",
|
||||
"openpose": "openpose",
|
||||
"canny": "canny",
|
||||
"hed": "hed",
|
||||
"depth": "depth",
|
||||
"normal": "normal",
|
||||
"seg": "seg",
|
||||
"scribble": "scribble",
|
||||
"fakescribbles": "fakescribbles",
|
||||
"hough": "hough",
|
||||
}
|
||||
|
||||
|
||||
class KudosModel:
|
||||
"""Calculate kudos for a given horde job payload. Tiny, lightweight cpu model.
|
||||
@@ -218,7 +261,11 @@ class KudosModel:
|
||||
data_samplers.append(
|
||||
payload["sampler_name"] if payload["sampler_name"] in KudosModel.KNOWN_SAMPLERS else "k_euler",
|
||||
)
|
||||
data_control_types.append(payload.get("control_type", "None"))
|
||||
control_type = payload.get("control_type", "None")
|
||||
mapped_control_type = CANONICAL_KUDOS_CONTROL_TYPES.get(control_type, control_type)
|
||||
if mapped_control_type not in KudosModel.KNOWN_CONTROL_TYPES:
|
||||
mapped_control_type = "None"
|
||||
data_control_types.append(mapped_control_type)
|
||||
sp = payload.get("source_processing", "txt2img")
|
||||
# Little hack until new model is out
|
||||
if sp == "remix":
|
||||
|
||||
@@ -11,7 +11,7 @@ from horde.bridge_reference import (
|
||||
is_official_bridge_version,
|
||||
)
|
||||
from horde.classes.base.worker import Worker
|
||||
from horde.consts import KNOWN_POST_PROCESSORS
|
||||
from horde.consts import KNOWN_POST_PROCESSORS, LEGACY_IMAGE_CONTROL_TYPES
|
||||
from horde.flask import db
|
||||
from horde.logger import logger
|
||||
from horde.model_reference import model_reference
|
||||
@@ -28,6 +28,7 @@ class ImageWorker(Worker):
|
||||
allow_painting = db.Column(db.Boolean, default=True, nullable=False, index=True)
|
||||
allow_post_processing = db.Column(db.Boolean, default=True, nullable=False, index=True)
|
||||
allow_controlnet = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
allow_extended_controlnet = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
allow_sdxl_controlnet = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
allow_lora = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
limit_max_steps = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
@@ -44,6 +45,7 @@ class ImageWorker(Worker):
|
||||
self.allow_painting = kwargs.get("allow_painting", True)
|
||||
self.allow_post_processing = kwargs.get("allow_post_processing", True)
|
||||
self.allow_controlnet = kwargs.get("allow_controlnet", False)
|
||||
self.allow_extended_controlnet = kwargs.get("allow_extended_controlnet", False)
|
||||
self.allow_sdxl_controlnet = kwargs.get("allow_sdxl_controlnet", False)
|
||||
self.allow_lora = kwargs.get("allow_lora", False)
|
||||
self.limit_max_steps = kwargs.get("limit_max_steps", False)
|
||||
@@ -128,6 +130,11 @@ class ImageWorker(Worker):
|
||||
return [False, "bridge_version"]
|
||||
if not check_bridge_capability("image_is_control", self.bridge_agent):
|
||||
return [False, "bridge_version"]
|
||||
if waiting_prompt.params["control_type"] not in LEGACY_IMAGE_CONTROL_TYPES:
|
||||
if not check_bridge_capability("extended_controlnet", self.bridge_agent):
|
||||
return [False, "bridge_version"]
|
||||
if not self.allow_extended_controlnet:
|
||||
return [False, "controlnet"]
|
||||
if not self.allow_controlnet:
|
||||
return [False, "controlnet"]
|
||||
if waiting_prompt.params.get("workflow") == "qr_code":
|
||||
@@ -218,6 +225,7 @@ class ImageWorker(Worker):
|
||||
ret_dict["painting"] = self.allow_painting if check_bridge_capability("inpainting", self.bridge_agent) else False
|
||||
ret_dict["post-processing"] = self.allow_post_processing
|
||||
ret_dict["controlnet"] = self.allow_controlnet
|
||||
ret_dict["extended_controlnet"] = self.allow_extended_controlnet
|
||||
ret_dict["sdxl_controlnet"] = self.allow_sdxl_controlnet
|
||||
ret_dict["lora"] = self.allow_lora
|
||||
return ret_dict
|
||||
|
||||
+122
-1
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
HORDE_VERSION = "5.1.5"
|
||||
HORDE_VERSION = "5.1.6"
|
||||
HORDE_API_VERSION = "2.5"
|
||||
|
||||
WHITELISTED_SERVICE_IPS = {
|
||||
@@ -32,6 +32,127 @@ KNOWN_POST_PROCESSORS = {
|
||||
"RestoreFormer": 1,
|
||||
}
|
||||
|
||||
# Control-map types the parameterized `annotation` alchemy form can produce. This is the closed set
|
||||
# the image-utilities backend can serve. It spells the line detector `mlsd` (its real name) where
|
||||
# legacy image-generation control_type still spells the same detector `hough`.
|
||||
KNOWN_CONTROL_TYPES = [
|
||||
"canny",
|
||||
"hed",
|
||||
"depth",
|
||||
"mlsd",
|
||||
"openpose",
|
||||
"normal",
|
||||
"scribble",
|
||||
"fakescribbles",
|
||||
"seg",
|
||||
"binary",
|
||||
"standard_lineart",
|
||||
"lineart",
|
||||
"lineart_anime",
|
||||
"lineart_anime_denoise",
|
||||
"pidinet",
|
||||
"scribble_xdog",
|
||||
"scribble_pidinet",
|
||||
"teed",
|
||||
"pyracanny",
|
||||
"midas_depth",
|
||||
"zoe_depth",
|
||||
"depth_anything",
|
||||
"depth_anything_v2",
|
||||
"normal_bae",
|
||||
"oneformer_ade20k",
|
||||
"oneformer_coco",
|
||||
"color",
|
||||
"shuffle",
|
||||
"recolor_luminance",
|
||||
"recolor_intensity",
|
||||
"tile",
|
||||
"tile_ttplanet_guided",
|
||||
"tile_ttplanet_simple",
|
||||
]
|
||||
|
||||
# The classic image-generation control types renderable by legacy workers (pre image-utilities).
|
||||
# Old bridges spell the line detector `hough`; KNOWN_CONTROL_TYPES spells the same detector `mlsd`.
|
||||
# A control_type outside this set is only dispatchable to bridge agents new enough to annotate it.
|
||||
LEGACY_IMAGE_CONTROL_TYPES = [
|
||||
"canny",
|
||||
"hed",
|
||||
"depth",
|
||||
"normal",
|
||||
"openpose",
|
||||
"seg",
|
||||
"scribble",
|
||||
"fakescribbles",
|
||||
"hough",
|
||||
]
|
||||
|
||||
# The full set the image-generation `control_type` field accepts: the unified KNOWN_CONTROL_TYPES
|
||||
# plus the legacy `hough` alias for `mlsd`, kept so existing clients keep validating.
|
||||
IMAGE_CONTROL_TYPES = [*KNOWN_CONTROL_TYPES, "hough"]
|
||||
|
||||
# Alchemy/interrogation forms whose result is an image delivered via R2 (upload URL minted on pop,
|
||||
# short-lived result cache) rather than an inline JSON payload. Post-processors are image-output by
|
||||
# nature; the controlnet `annotation` form also emits an image (the control map).
|
||||
IMAGE_RESULT_ALCHEMY_FORMS = set(KNOWN_POST_PROCESSORS) | {"annotation"}
|
||||
|
||||
# Detector cost classes for the parameterized `annotation` alchemy form, sourced from the
|
||||
# image-utilities annotator registry runtimes. Each control type maps to a per-tile kudos
|
||||
# multiplier reflecting the horde resources its detector consumes:
|
||||
# - WEIGHTLESS: pure OpenCV/numpy detectors that load no model weights.
|
||||
# - WEIGHTED: small/medium annotator checkpoints (measured ~2-8s CPU).
|
||||
# - HUB: large ViT/transformers-hub detectors (measured ~13-65s CPU).
|
||||
# An unlisted control type falls back to the middle WEIGHTED class.
|
||||
ANNOTATION_KUDOS_BUCKET_WEIGHTLESS = 3
|
||||
ANNOTATION_KUDOS_BUCKET_WEIGHTED = 5
|
||||
ANNOTATION_KUDOS_BUCKET_HUB = 8
|
||||
ANNOTATION_KUDOS_DEFAULT_BUCKET = ANNOTATION_KUDOS_BUCKET_WEIGHTED
|
||||
|
||||
ANNOTATION_DETECTOR_KUDOS_BUCKETS = {
|
||||
"canny": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"binary": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"scribble": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"scribble_xdog": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"pyracanny": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"color": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"shuffle": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"recolor_luminance": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"recolor_intensity": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"tile": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"tile_ttplanet_guided": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"tile_ttplanet_simple": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"standard_lineart": ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
"hed": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"fakescribbles": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"mlsd": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"openpose": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"depth": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"seg": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"lineart": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"lineart_anime": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"lineart_anime_denoise": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"pidinet": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"scribble_pidinet": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"teed": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"normal_bae": ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
"midas_depth": ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
"zoe_depth": ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
"depth_anything": ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
"depth_anything_v2": ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
"normal": ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
"oneformer_ade20k": ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
"oneformer_coco": ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
}
|
||||
|
||||
|
||||
def annotation_detector_kudos_bucket(control_type):
|
||||
"""Return the per-tile kudos multiplier for an annotation control type.
|
||||
|
||||
Unknown or missing control types fall back to the middle WEIGHTED bucket so
|
||||
a novel detector is never priced below its likely cost class.
|
||||
"""
|
||||
return ANNOTATION_DETECTOR_KUDOS_BUCKETS.get(control_type, ANNOTATION_KUDOS_DEFAULT_BUCKET)
|
||||
|
||||
|
||||
KNOWN_UPSCALERS = [
|
||||
"RealESRGAN_x4plus",
|
||||
"RealESRGAN_x2plus",
|
||||
|
||||
@@ -33,6 +33,7 @@ from horde.classes.stable.interrogation_worker import InterrogationWorker
|
||||
from horde.classes.stable.processing_generation import ImageProcessingGeneration
|
||||
from horde.classes.stable.waiting_prompt import ImageWaitingPrompt
|
||||
from horde.classes.stable.worker import ImageWorker
|
||||
from horde.consts import LEGACY_IMAGE_CONTROL_TYPES
|
||||
from horde.database.classes import FakeWPRow
|
||||
from horde.database.kudos_legacy_projection import consume_user_reservation
|
||||
from horde.database.kudos_reservations import reserve_kudos
|
||||
@@ -1009,6 +1010,13 @@ def get_sorted_wp_filtered_to_worker(worker, models_list=None, blacklist=None, p
|
||||
and_(
|
||||
worker.allow_controlnet == True, # noqa E712
|
||||
check_bridge_capability("controlnet", worker.bridge_agent),
|
||||
or_(
|
||||
ImageWaitingPrompt.params["control_type"].astext.in_(LEGACY_IMAGE_CONTROL_TYPES),
|
||||
and_(
|
||||
check_bridge_capability("extended_controlnet", worker.bridge_agent),
|
||||
worker.allow_extended_controlnet == True, # noqa E712
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
or_(
|
||||
@@ -1117,6 +1125,7 @@ def count_skipped_image_wp(worker, models_list=None, blacklist=None, priority_us
|
||||
can_ti = check_bridge_capability("textual_inversion", bridge_agent)
|
||||
can_pp = check_bridge_capability("post-processing", bridge_agent)
|
||||
can_controlnet = check_bridge_capability("controlnet", bridge_agent)
|
||||
can_extended_controlnet = check_bridge_capability("extended_controlnet", bridge_agent)
|
||||
can_hires = check_bridge_capability("hires_fix", bridge_agent)
|
||||
can_return_ctrl = check_bridge_capability("return_control_map", bridge_agent)
|
||||
can_tiling = check_bridge_capability("tiling", bridge_agent)
|
||||
@@ -1211,6 +1220,22 @@ def count_skipped_image_wp(worker, models_list=None, blacklist=None, priority_us
|
||||
# controlnet
|
||||
if worker.allow_controlnet is False or not can_controlnet:
|
||||
count_exprs["_controlnet_raw"] = count_distinct_wp(ImageWaitingPrompt.params.has_key("control_type"))
|
||||
elif not can_extended_controlnet:
|
||||
# A controlnet-capable but pre-extended worker cannot render control types outside the classic set.
|
||||
count_exprs["_controlnet_extended_bridge_raw"] = count_distinct_wp(
|
||||
and_(
|
||||
ImageWaitingPrompt.params.has_key("control_type"),
|
||||
ImageWaitingPrompt.params["control_type"].astext.notin_(LEGACY_IMAGE_CONTROL_TYPES),
|
||||
),
|
||||
)
|
||||
elif worker.allow_extended_controlnet is False:
|
||||
# An extended-capable worker that opted out of extended types skips them by worker choice.
|
||||
count_exprs["_controlnet_extended_choice_raw"] = count_distinct_wp(
|
||||
and_(
|
||||
ImageWaitingPrompt.params.has_key("control_type"),
|
||||
ImageWaitingPrompt.params["control_type"].astext.notin_(LEGACY_IMAGE_CONTROL_TYPES),
|
||||
),
|
||||
)
|
||||
|
||||
# performance (slow workers)
|
||||
if worker.speed <= 500000:
|
||||
@@ -1337,6 +1362,12 @@ def count_skipped_image_wp(worker, models_list=None, blacklist=None, priority_us
|
||||
ret_dict["controlnet"] = controlnet_count
|
||||
else:
|
||||
bridge_version_count += controlnet_count
|
||||
extended_controlnet_bridge_count = raw.get("_controlnet_extended_bridge_raw", 0) or 0
|
||||
if extended_controlnet_bridge_count > 0:
|
||||
bridge_version_count += extended_controlnet_bridge_count
|
||||
extended_controlnet_choice_count = raw.get("_controlnet_extended_choice_raw", 0) or 0
|
||||
if extended_controlnet_choice_count > 0:
|
||||
ret_dict["controlnet"] = ret_dict.get("controlnet", 0) + extended_controlnet_choice_count
|
||||
|
||||
# performance
|
||||
perf_count = (raw.get("_perf_slow", 0) or 0) + (raw.get("_perf_extra_slow", 0) or 0)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
[project]
|
||||
name = "AI-Horde"
|
||||
version = "5.1.3"
|
||||
version = "5.1.6"
|
||||
description = "A Crowdsourced Generative AI cluster for everyone."
|
||||
authors = [
|
||||
{name = "db0", email = "mail@dbzer0.com"},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Run with psql in its default autocommit mode. Do NOT pass --single-transaction: this
|
||||
-- file contains CREATE INDEX CONCURRENTLY, which cannot run inside a transaction block.
|
||||
-- Pass -v ON_ERROR_STOP=1 so a failure halts rather than continuing. Every statement is
|
||||
-- idempotent, so a halted run can be repeated once the cause is addressed; a failed
|
||||
-- concurrent index build leaves an INVALID index that must be dropped before the rerun.
|
||||
--
|
||||
-- Safe to apply ahead of the deploy. An instance that predates this migration neither
|
||||
-- selects allow_extended_controlnet nor reads the annotation-type table, and the column
|
||||
-- default covers worker INSERTs that omit it.
|
||||
|
||||
SET lock_timeout = '3s';
|
||||
|
||||
-- The worker's opt-in to control types outside the classic set. Defaulting false is what
|
||||
-- makes the offer fail-closed: an existing worker keeps serving legacy control types only
|
||||
-- until its operator turns the flag on. The pop candidate query and the skipped-request
|
||||
-- counts both filter on it, matching the other capability columns on workers.
|
||||
ALTER TABLE workers ADD COLUMN IF NOT EXISTS allow_extended_controlnet BOOLEAN NOT NULL DEFAULT false;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_workers_allow_extended_controlnet
|
||||
ON public.workers USING btree (allow_extended_controlnet);
|
||||
|
||||
-- The annotation control types an alchemist advertises at check-in, mirroring how
|
||||
-- interrogation_worker_forms records its offered forms. This backs worker details and
|
||||
-- moderation; pop-time matching reads the live pop payload instead, so a stale or empty
|
||||
-- row set never gates dispatch.
|
||||
CREATE TABLE IF NOT EXISTS interrogation_worker_annotation_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
worker_id UUID NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
|
||||
annotation_type VARCHAR(64)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
SPDX-FileCopyrightText: Tazlin <tazlin.on.github@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
@@ -0,0 +1,120 @@
|
||||
# SPDX-FileCopyrightText: 2026 Tazlin <tazlin@haidra.net>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Cross-repo wire-contract client, executed under the local horde_sdk venv.
|
||||
|
||||
Reads a JSON job description on stdin, performs real HTTP pops against a live
|
||||
AI-Horde server, and validates the responses with the local horde_sdk response
|
||||
models. Emits a single-line JSON verdict on stdout. It imports only horde_sdk,
|
||||
requests, and the standard library: never the AI-Horde ``horde`` package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import requests
|
||||
from horde_sdk.ai_horde_api.apimodels.alchemy.pop import AlchemyJobPopResponse, AlchemyPopRequest
|
||||
from horde_sdk.ai_horde_api.apimodels.generate.pop import (
|
||||
ImageGenerateJobPopRequest,
|
||||
ImageGenerateJobPopResponse,
|
||||
)
|
||||
from horde_sdk.generation_parameters.alchemy.consts import KNOWN_ANNOTATION_CONTROL_TYPES
|
||||
|
||||
|
||||
def _headers(cfg: dict) -> dict:
|
||||
return {"apikey": cfg["apikey"], "Client-Agent": cfg["client_agent"]}
|
||||
|
||||
|
||||
def _check_alchemy(cfg: dict) -> dict:
|
||||
body = cfg["alchemy_pop_body"]
|
||||
|
||||
request_model = AlchemyPopRequest(
|
||||
apikey=cfg["apikey"],
|
||||
name=body["name"],
|
||||
priority_usernames=[],
|
||||
forms=body["forms"],
|
||||
annotation_types=body["annotation_types"],
|
||||
amount=1,
|
||||
)
|
||||
reserialized = request_model.model_dump(by_alias=True, exclude_none=True, mode="json")
|
||||
if reserialized.get("annotation_types") != body["annotation_types"]:
|
||||
raise AssertionError(f"SDK AlchemyPopRequest dropped annotation_types: {reserialized!r}")
|
||||
|
||||
response = requests.post(cfg["base_url"] + "/api/v2/interrogate/pop", json=body, headers=_headers(cfg), timeout=30)
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
|
||||
parsed = AlchemyJobPopResponse.model_validate(raw)
|
||||
forms = parsed.forms or []
|
||||
if not forms:
|
||||
raise AssertionError(f"alchemy pop returned no forms: {raw!r}")
|
||||
form = forms[0]
|
||||
if form.form != "annotation":
|
||||
raise AssertionError(f"unexpected form: {form.form!r} in {raw!r}")
|
||||
if form.payload is None or form.payload.control_type is None:
|
||||
raise AssertionError(f"SDK did not parse control_type from payload: {raw!r}")
|
||||
if form.payload.control_type != KNOWN_ANNOTATION_CONTROL_TYPES.canny:
|
||||
raise AssertionError(f"control_type did not round-trip: {form.payload.control_type!r}")
|
||||
if not form.r2_upload:
|
||||
raise AssertionError(f"annotation pop missing r2_upload: {raw!r}")
|
||||
|
||||
return {
|
||||
"form": str(form.form),
|
||||
"control_type": str(form.payload.control_type),
|
||||
"control_type_is_enum": isinstance(form.payload.control_type, KNOWN_ANNOTATION_CONTROL_TYPES),
|
||||
"has_r2_upload": bool(form.r2_upload),
|
||||
"request_roundtrip_annotation_types": reserialized.get("annotation_types"),
|
||||
}
|
||||
|
||||
|
||||
def _check_image(cfg: dict) -> dict:
|
||||
body = cfg["image_pop_body"]
|
||||
|
||||
request_model = ImageGenerateJobPopRequest(
|
||||
apikey=cfg["apikey"],
|
||||
name=body["name"],
|
||||
priority_usernames=[],
|
||||
models=body["models"],
|
||||
bridge_agent=body["bridge_agent"],
|
||||
amount=body["amount"],
|
||||
max_pixels=body["max_pixels"],
|
||||
allow_controlnet=True,
|
||||
allow_extended_controlnet=body["allow_extended_controlnet"],
|
||||
)
|
||||
reserialized = request_model.model_dump(by_alias=True, exclude_none=True, mode="json")
|
||||
if reserialized.get("allow_extended_controlnet") is not True:
|
||||
raise AssertionError(f"SDK ImageGenerateJobPopRequest dropped allow_extended_controlnet: {reserialized!r}")
|
||||
|
||||
response = requests.post(cfg["base_url"] + "/api/v2/generate/pop", json=body, headers=_headers(cfg), timeout=30)
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
|
||||
parsed = ImageGenerateJobPopResponse.model_validate(raw)
|
||||
if parsed.id_ is None:
|
||||
raise AssertionError(f"image pop matched no job: {raw!r}")
|
||||
if parsed.payload is None or parsed.payload.control_type is None:
|
||||
raise AssertionError(f"SDK did not parse control_type from image payload: {raw!r}")
|
||||
|
||||
return {
|
||||
"id_present": parsed.id_ is not None,
|
||||
"control_type": str(parsed.payload.control_type),
|
||||
"request_roundtrip_allow_extended_controlnet": reserialized.get("allow_extended_controlnet"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = json.load(sys.stdin)
|
||||
verdict = {
|
||||
"alchemy": _check_alchemy(cfg),
|
||||
"image": _check_image(cfg),
|
||||
"ok": True,
|
||||
}
|
||||
sys.stdout.write(json.dumps(verdict))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,6 +3,15 @@
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import pytest
|
||||
|
||||
# The annotation form is image-output: its pop mints an R2 upload URL, so the object store must be
|
||||
# provisioned for this module rather than depending on a co-running marked module to bring it up.
|
||||
pytestmark = [
|
||||
pytest.mark.object_storage,
|
||||
pytest.mark.usefixtures("object_store_ready"),
|
||||
]
|
||||
|
||||
|
||||
def test_simple_alchemy(client, request_headers: dict[str, str]) -> None:
|
||||
async_dict = {
|
||||
@@ -114,6 +123,179 @@ def test_alchemy_vectorize(client, request_headers: dict[str, str]) -> None:
|
||||
assert retrieve_results["state"] == "done"
|
||||
|
||||
|
||||
def test_alchemy_annotation(client, request_headers: dict[str, str]) -> None:
|
||||
async_dict = {
|
||||
"forms": [
|
||||
{"name": "annotation", "payload": {"control_type": "canny"}},
|
||||
],
|
||||
"source_image": "https://github.com/Haidra-Org/AI-Horde/blob/main/icon.png?raw=true",
|
||||
}
|
||||
async_req = client.post("/api/v2/interrogate/async", json=async_dict, headers=request_headers)
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
async_results = async_req.get_json()
|
||||
req_id = async_results["id"]
|
||||
|
||||
pop_dict = {
|
||||
"name": "CICD Fake Alchemist",
|
||||
"forms": ["annotation"],
|
||||
"annotation_types": ["canny"],
|
||||
"bridge_agent": request_headers["Client-Agent"],
|
||||
"max_tiles": 96,
|
||||
}
|
||||
try:
|
||||
pop_req = client.post("/api/v2/interrogate/pop", json=pop_dict, headers=request_headers)
|
||||
except Exception:
|
||||
client.delete(f"/api/v2/interrogate/status/{req_id}", headers=request_headers)
|
||||
raise
|
||||
|
||||
assert pop_req.status_code < 400, pop_req.get_data(as_text=True)
|
||||
pop_results = pop_req.get_json()
|
||||
|
||||
popped_form = pop_results["forms"][0]
|
||||
job_id = popped_form["id"]
|
||||
assert job_id is not None, pop_results
|
||||
assert popped_form["form"] == "annotation"
|
||||
# annotation is an image-output form, so the pop must carry an R2 upload destination.
|
||||
assert popped_form["r2_upload"], pop_results
|
||||
# The parameterized control type must survive round-trip to the worker via the form payload.
|
||||
assert popped_form["payload"]["control_type"] == "canny", pop_results
|
||||
|
||||
submit_dict = {
|
||||
"id": job_id,
|
||||
"result": {"annotation": "R2"},
|
||||
"state": "ok",
|
||||
}
|
||||
submit_req = client.post("/api/v2/interrogate/submit", json=submit_dict, headers=request_headers)
|
||||
assert submit_req.status_code < 400, submit_req.get_data(as_text=True)
|
||||
submit_results = submit_req.get_json()
|
||||
assert submit_results["reward"] > 0
|
||||
|
||||
retrieve_req = client.get(f"/api/v2/interrogate/status/{req_id}", headers=request_headers)
|
||||
assert retrieve_req.status_code < 400, retrieve_req.get_data(as_text=True)
|
||||
retrieve_results = retrieve_req.get_json()
|
||||
|
||||
assert len(retrieve_results["forms"]) == 1
|
||||
gen = retrieve_results["forms"][0]
|
||||
assert gen["form"] == "annotation"
|
||||
assert gen["state"] == "done"
|
||||
assert retrieve_results["state"] == "done"
|
||||
|
||||
|
||||
def _annotation_reward_for_control_type(client, request_headers: dict[str, str], control_type: str) -> float:
|
||||
"""Run a full annotation pop->submit cycle for ``control_type`` and return the awarded kudos."""
|
||||
async_dict = {
|
||||
"forms": [
|
||||
{"name": "annotation", "payload": {"control_type": control_type}},
|
||||
],
|
||||
"source_image": "https://github.com/Haidra-Org/AI-Horde/blob/main/icon.png?raw=true",
|
||||
}
|
||||
async_req = client.post("/api/v2/interrogate/async", json=async_dict, headers=request_headers)
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
req_id = async_req.get_json()["id"]
|
||||
|
||||
try:
|
||||
pop_dict = {
|
||||
"name": "CICD Fake Alchemist",
|
||||
"forms": ["annotation"],
|
||||
"annotation_types": [control_type],
|
||||
"bridge_agent": request_headers["Client-Agent"],
|
||||
"max_tiles": 96,
|
||||
}
|
||||
pop_req = client.post("/api/v2/interrogate/pop", json=pop_dict, headers=request_headers)
|
||||
assert pop_req.status_code < 400, pop_req.get_data(as_text=True)
|
||||
job_id = pop_req.get_json()["forms"][0]["id"]
|
||||
|
||||
submit_dict = {"id": job_id, "result": {"annotation": "R2"}, "state": "ok"}
|
||||
submit_req = client.post("/api/v2/interrogate/submit", json=submit_dict, headers=request_headers)
|
||||
assert submit_req.status_code < 400, submit_req.get_data(as_text=True)
|
||||
return submit_req.get_json()["reward"]
|
||||
finally:
|
||||
client.delete(f"/api/v2/interrogate/status/{req_id}", headers=request_headers)
|
||||
|
||||
|
||||
def test_alchemy_annotation_bucket_prices_by_detector_class(client, request_headers: dict[str, str]) -> None:
|
||||
"""Pricing uses the detector cost bucket: a heavy hub detector (oneformer) pays more per tile
|
||||
than a weightless one (canny) for the same source image."""
|
||||
canny_reward = _annotation_reward_for_control_type(client, request_headers, "canny")
|
||||
oneformer_reward = _annotation_reward_for_control_type(client, request_headers, "oneformer_ade20k")
|
||||
assert canny_reward > 0
|
||||
assert oneformer_reward > canny_reward, (canny_reward, oneformer_reward)
|
||||
|
||||
|
||||
def _pop_annotation(client, request_headers: dict[str, str], pop_dict: dict) -> dict:
|
||||
async_dict = {
|
||||
"forms": [
|
||||
{"name": "annotation", "payload": {"control_type": "canny"}},
|
||||
],
|
||||
"source_image": "https://github.com/Haidra-Org/AI-Horde/blob/main/icon.png?raw=true",
|
||||
}
|
||||
async_req = client.post("/api/v2/interrogate/async", json=async_dict, headers=request_headers)
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
req_id = async_req.get_json()["id"]
|
||||
|
||||
try:
|
||||
pop_req = client.post("/api/v2/interrogate/pop", json=pop_dict, headers=request_headers)
|
||||
assert pop_req.status_code < 400, pop_req.get_data(as_text=True)
|
||||
return pop_req.get_json()
|
||||
finally:
|
||||
client.delete(f"/api/v2/interrogate/status/{req_id}", headers=request_headers)
|
||||
|
||||
|
||||
def test_alchemy_annotation_non_matching_type_does_not_pop(client, request_headers: dict[str, str]) -> None:
|
||||
"""A worker advertising annotation types that exclude the job's control_type receives no job."""
|
||||
pop_results = _pop_annotation(
|
||||
client,
|
||||
request_headers,
|
||||
{
|
||||
"name": "CICD Fake Alchemist",
|
||||
"forms": ["annotation"],
|
||||
"annotation_types": ["depth"],
|
||||
"bridge_agent": request_headers["Client-Agent"],
|
||||
"max_tiles": 96,
|
||||
},
|
||||
)
|
||||
assert not pop_results.get("forms"), pop_results
|
||||
|
||||
|
||||
def test_alchemy_annotation_absent_types_does_not_pop(client, request_headers: dict[str, str]) -> None:
|
||||
"""The annotation form is fail-closed: a worker that advertises no annotation types matches no jobs."""
|
||||
pop_results = _pop_annotation(
|
||||
client,
|
||||
request_headers,
|
||||
{
|
||||
"name": "CICD Fake Alchemist",
|
||||
"forms": ["annotation"],
|
||||
"bridge_agent": request_headers["Client-Agent"],
|
||||
"max_tiles": 96,
|
||||
},
|
||||
)
|
||||
assert not pop_results.get("forms"), pop_results
|
||||
|
||||
|
||||
def test_alchemy_annotation_rejects_unknown_control_type(client, request_headers: dict[str, str]) -> None:
|
||||
"""The server validates control_type against its closed set and rejects anything outside it."""
|
||||
async_dict = {
|
||||
"forms": [
|
||||
{"name": "annotation", "payload": {"control_type": "not_a_real_detector"}},
|
||||
],
|
||||
"source_image": "https://github.com/Haidra-Org/AI-Horde/blob/main/icon.png?raw=true",
|
||||
}
|
||||
async_req = client.post("/api/v2/interrogate/async", json=async_dict, headers=request_headers)
|
||||
assert async_req.status_code == 400, async_req.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_alchemy_annotation_requires_control_type(client, request_headers: dict[str, str]) -> None:
|
||||
"""An annotation form with no control_type payload is rejected rather than silently defaulted."""
|
||||
async_dict = {
|
||||
"forms": [
|
||||
{"name": "annotation"},
|
||||
],
|
||||
"source_image": "https://github.com/Haidra-Org/AI-Horde/blob/main/icon.png?raw=true",
|
||||
}
|
||||
async_req = client.post("/api/v2/interrogate/async", json=async_dict, headers=request_headers)
|
||||
assert async_req.status_code == 400, async_req.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_alchemist_palette_and_describe(client, request_headers: dict[str, str]) -> None:
|
||||
async_dict = {
|
||||
"forms": [
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# SPDX-FileCopyrightText: 2026 Tazlin <tazlin@haidra.net>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Persistence + backwards-compatibility coverage for advertised annotation types.
|
||||
|
||||
An alchemy worker's advertised ``annotation_types`` are stored for display and
|
||||
moderation only. Pop-time matching stays on the live pop payload (fail-closed),
|
||||
so these tests assert:
|
||||
|
||||
- check_in persists the advertised set and updates it on the next check-in;
|
||||
- worker details expose the set additively;
|
||||
- a legacy alchemist (no annotation_types anywhere) keeps working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
AGENT = "aihorde_ci_client:1.0:(test)ci"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_rate_limit():
|
||||
from horde.limiter import limiter
|
||||
|
||||
previous = limiter.enabled
|
||||
limiter.enabled = False
|
||||
yield
|
||||
limiter.enabled = previous
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"apikey": api_key, "Client-Agent": AGENT}
|
||||
|
||||
|
||||
def _expire_caches() -> None:
|
||||
"""Flush the (fake) redis caches so a details read reflects committed DB state
|
||||
rather than the snapshot taken at check-in time."""
|
||||
from horde import horde_redis as horde_redis_module
|
||||
|
||||
redis_conn = horde_redis_module.horde_redis
|
||||
seen: dict[int, object] = {}
|
||||
for client in [redis_conn.horde_r, redis_conn.horde_local_r, *redis_conn.all_horde_redis]:
|
||||
if client is not None:
|
||||
seen[id(client)] = client
|
||||
for client in seen.values():
|
||||
try:
|
||||
client.flushdb()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _worker_details_by_name(client, api_key: str, worker_name: str) -> dict:
|
||||
_expire_caches()
|
||||
resp = client.get(f"/api/v2/workers/name/{worker_name}", headers=_headers(api_key))
|
||||
assert resp.status_code == 200, resp.get_data(as_text=True)
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def _alchemy_pop(client, api_key: str, pop_dict: dict):
|
||||
return client.post("/api/v2/interrogate/pop", json=pop_dict, headers=_headers(api_key))
|
||||
|
||||
|
||||
class TestAnnotationTypePersistence:
|
||||
def test_check_in_persists_and_updates_the_advertised_set(self, client, api_key):
|
||||
worker_name = "AnnotationPersistWorker"
|
||||
|
||||
first = _alchemy_pop(
|
||||
client,
|
||||
api_key,
|
||||
{
|
||||
"name": worker_name,
|
||||
"forms": ["caption"],
|
||||
"annotation_types": ["canny", "depth"],
|
||||
"bridge_agent": AGENT,
|
||||
"max_tiles": 96,
|
||||
},
|
||||
)
|
||||
assert first.status_code == 200, first.get_data(as_text=True)
|
||||
|
||||
details = _worker_details_by_name(client, api_key, worker_name)
|
||||
assert "annotation_types" in details
|
||||
assert set(details["annotation_types"]) == {"canny", "depth"}
|
||||
# Additive only: the legacy forms key still exists and keeps its shape.
|
||||
assert set(details["forms"]) == {"caption"}
|
||||
|
||||
# A subsequent check-in with a different set replaces (not accumulates) it.
|
||||
second = _alchemy_pop(
|
||||
client,
|
||||
api_key,
|
||||
{
|
||||
"name": worker_name,
|
||||
"forms": ["caption"],
|
||||
"annotation_types": ["seg"],
|
||||
"bridge_agent": AGENT,
|
||||
"max_tiles": 96,
|
||||
},
|
||||
)
|
||||
assert second.status_code == 200, second.get_data(as_text=True)
|
||||
|
||||
details = _worker_details_by_name(client, api_key, worker_name)
|
||||
assert set(details["annotation_types"]) == {"seg"}
|
||||
|
||||
def test_absurdly_long_advertised_list_is_capped(self, app, client, api_key):
|
||||
# The pop endpoint already validates advertised types against the known set, so the
|
||||
# internal 100-entry abuse cap is exercised directly on the model here.
|
||||
worker_name = "AnnotationCapWorker"
|
||||
resp = _alchemy_pop(
|
||||
client,
|
||||
api_key,
|
||||
{"name": worker_name, "forms": ["caption"], "bridge_agent": AGENT, "max_tiles": 96},
|
||||
)
|
||||
assert resp.status_code == 200, resp.get_data(as_text=True)
|
||||
|
||||
from horde.classes.stable.interrogation_worker import InterrogationWorker
|
||||
from horde.flask import db
|
||||
|
||||
with app.app_context():
|
||||
worker = db.session.query(InterrogationWorker).filter_by(name=worker_name).first()
|
||||
assert worker is not None
|
||||
worker.set_annotation_types([f"type_{i}" for i in range(250)])
|
||||
names = worker.get_annotation_type_names()
|
||||
|
||||
assert len(names) == 100
|
||||
|
||||
|
||||
class TestLegacyAlchemistBackwardsCompatibility:
|
||||
def test_legacy_alchemist_pops_a_queued_form_and_raises_nothing(self, client, api_key):
|
||||
"""A legacy alchemist (no annotation_types anywhere, only legacy forms) still receives a
|
||||
queued legacy form and its check-in/details flow raises nothing."""
|
||||
async_dict = {
|
||||
"forms": [
|
||||
{"name": "caption"},
|
||||
],
|
||||
"source_image": "https://github.com/Haidra-Org/AI-Horde/blob/main/icon.png?raw=true",
|
||||
}
|
||||
async_req = client.post("/api/v2/interrogate/async", json=async_dict, headers=_headers(api_key))
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
req_id = async_req.get_json()["id"]
|
||||
|
||||
worker_name = "LegacyAlchemist"
|
||||
try:
|
||||
pop_dict = {
|
||||
"name": worker_name,
|
||||
"forms": ["caption", "strip_background"],
|
||||
"bridge_agent": AGENT,
|
||||
"max_tiles": 96,
|
||||
}
|
||||
pop_req = _alchemy_pop(client, api_key, pop_dict)
|
||||
assert pop_req.status_code == 200, pop_req.get_data(as_text=True)
|
||||
pop_results = pop_req.get_json()
|
||||
# The legacy worker still gets the queued caption job with no annotation gating.
|
||||
assert pop_results.get("forms"), pop_results
|
||||
assert pop_results["forms"][0]["form"] == "caption", pop_results
|
||||
|
||||
# Details flow raises nothing and reports an empty (but present) annotation set.
|
||||
details = _worker_details_by_name(client, api_key, worker_name)
|
||||
assert details["annotation_types"] == []
|
||||
assert "caption" in details["forms"]
|
||||
finally:
|
||||
client.delete(f"/api/v2/interrogate/status/{req_id}", headers=_headers(api_key))
|
||||
@@ -0,0 +1,174 @@
|
||||
# SPDX-FileCopyrightText: 2026 Tazlin <tazlin@haidra.net>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Dispatch gating for the unified image-generation control types.
|
||||
|
||||
The classic controlnet set is renderable by any controlnet-capable worker. The
|
||||
extended set (everything outside ``LEGACY_IMAGE_CONTROL_TYPES``) may only be
|
||||
dispatched to reGen bridge agents new enough to annotate it, otherwise the job
|
||||
would be sent to a worker that cannot fulfil it. The legacy ``hough`` alias must
|
||||
keep matching old workers unchanged.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from horde.bridge_reference import EXTENDED_CONTROLNET_REGEN_VERSION
|
||||
|
||||
TEST_MODELS = ["stable_diffusion"]
|
||||
SDXL_MODELS = ["AlbedoBase XL (SDXL)"]
|
||||
|
||||
# A pre-extended reGen agent: has classic controlnet, lacks extended controlnet.
|
||||
OLD_BRIDGE_AGENT = "AI Horde Worker reGen:13:https://github.com/Haidra-Org/horde-worker-reGen"
|
||||
# A reGen agent at the extended controlnet threshold.
|
||||
NEW_BRIDGE_AGENT = f"AI Horde Worker reGen:{EXTENDED_CONTROLNET_REGEN_VERSION}:https://github.com/Haidra-Org/horde-worker-reGen"
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.object_storage,
|
||||
pytest.mark.usefixtures("object_store_ready"),
|
||||
]
|
||||
|
||||
|
||||
def _load_image_as_b64(image_path: str) -> str:
|
||||
final_src_img = Image.open(image_path)
|
||||
buffer = BytesIO()
|
||||
final_src_img.save(buffer, format="Webp", quality=50, exact=True)
|
||||
return base64.b64encode(buffer.getvalue()).decode("utf8")
|
||||
|
||||
|
||||
def _controlnet_async_dict(control_type: str, models: list[str] | None = None) -> dict:
|
||||
return {
|
||||
"prompt": "a controlnet-guided horde of robots",
|
||||
"nsfw": True,
|
||||
"censor_nsfw": False,
|
||||
"r2": True,
|
||||
"shared": True,
|
||||
"trusted_workers": True,
|
||||
"params": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"steps": 20,
|
||||
"cfg_scale": 7.5,
|
||||
"sampler_name": "k_euler_a",
|
||||
"control_type": control_type,
|
||||
},
|
||||
"models": models if models is not None else TEST_MODELS,
|
||||
"source_image": _load_image_as_b64("img_stable/0.jpg"),
|
||||
"source_processing": "img2img",
|
||||
}
|
||||
|
||||
|
||||
def _pop_dict(bridge_agent: str, allow_extended_controlnet: bool = True) -> dict:
|
||||
return {
|
||||
"name": "CICD Fake Dreamer",
|
||||
"models": TEST_MODELS,
|
||||
"bridge_agent": bridge_agent,
|
||||
"nsfw": True,
|
||||
"amount": 10,
|
||||
"max_pixels": 4194304,
|
||||
"allow_img2img": True,
|
||||
"allow_painting": True,
|
||||
"allow_unsafe_ipaddr": True,
|
||||
"allow_post_processing": True,
|
||||
"allow_controlnet": True,
|
||||
"allow_extended_controlnet": allow_extended_controlnet,
|
||||
"allow_sdxl_controlnet": True,
|
||||
"allow_lora": True,
|
||||
}
|
||||
|
||||
|
||||
def test_new_control_type_skips_old_bridge_but_matches_new(client, request_headers: dict[str, str]) -> None:
|
||||
async_req = client.post("/api/v2/generate/async", json=_controlnet_async_dict("lineart"), headers=request_headers)
|
||||
# The unified control set is accepted by the async validator.
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
req_id = async_req.get_json()["id"]
|
||||
|
||||
try:
|
||||
old_pop = client.post("/api/v2/generate/pop", json=_pop_dict(OLD_BRIDGE_AGENT), headers=request_headers)
|
||||
assert old_pop.status_code < 400, old_pop.get_data(as_text=True)
|
||||
old_results = old_pop.get_json()
|
||||
# A pre-extended controlnet worker cannot annotate `lineart`, so it is skipped as too old.
|
||||
assert old_results["id"] is None, old_results
|
||||
assert old_results["skipped"].get("bridge_version", 0) >= 1, old_results
|
||||
|
||||
new_pop = client.post("/api/v2/generate/pop", json=_pop_dict(NEW_BRIDGE_AGENT), headers=request_headers)
|
||||
assert new_pop.status_code < 400, new_pop.get_data(as_text=True)
|
||||
new_results = new_pop.get_json()
|
||||
# The extended-capable worker with the opt-in flag matches the same job.
|
||||
assert new_results["id"] is not None, new_results
|
||||
assert new_results["payload"]["control_type"] == "lineart", new_results
|
||||
finally:
|
||||
client.delete(f"/api/v2/generate/status/{req_id}", headers=request_headers)
|
||||
|
||||
|
||||
def test_new_control_type_skips_new_bridge_when_flag_off(client, request_headers: dict[str, str]) -> None:
|
||||
async_req = client.post("/api/v2/generate/async", json=_controlnet_async_dict("lineart"), headers=request_headers)
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
req_id = async_req.get_json()["id"]
|
||||
|
||||
try:
|
||||
pop = client.post(
|
||||
"/api/v2/generate/pop",
|
||||
json=_pop_dict(NEW_BRIDGE_AGENT, allow_extended_controlnet=False),
|
||||
headers=request_headers,
|
||||
)
|
||||
assert pop.status_code < 400, pop.get_data(as_text=True)
|
||||
results = pop.get_json()
|
||||
# An extended-capable bridge that opts out of extended types must not receive `lineart`.
|
||||
assert results["id"] is None, results
|
||||
# The opt-out is a worker-choice skip, attributed to the controlnet bucket rather than bridge_version.
|
||||
assert results["skipped"].get("controlnet", 0) >= 1, results
|
||||
finally:
|
||||
client.delete(f"/api/v2/generate/status/{req_id}", headers=request_headers)
|
||||
|
||||
|
||||
def test_classic_control_type_matches_new_bridge_regardless_of_flag(client, request_headers: dict[str, str]) -> None:
|
||||
async_req = client.post("/api/v2/generate/async", json=_controlnet_async_dict("canny"), headers=request_headers)
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
req_id = async_req.get_json()["id"]
|
||||
|
||||
try:
|
||||
# The extended opt-out must not affect classic control types.
|
||||
pop = client.post(
|
||||
"/api/v2/generate/pop",
|
||||
json=_pop_dict(NEW_BRIDGE_AGENT, allow_extended_controlnet=False),
|
||||
headers=request_headers,
|
||||
)
|
||||
assert pop.status_code < 400, pop.get_data(as_text=True)
|
||||
results = pop.get_json()
|
||||
assert results["id"] is not None, results
|
||||
assert results["payload"]["control_type"] == "canny", results
|
||||
finally:
|
||||
client.delete(f"/api/v2/generate/status/{req_id}", headers=request_headers)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("control_type", ["canny", "teed"])
|
||||
def test_sdxl_baseline_rejects_controlnet(client, request_headers: dict[str, str], control_type: str) -> None:
|
||||
async_req = client.post(
|
||||
"/api/v2/generate/async",
|
||||
json=_controlnet_async_dict(control_type, models=SDXL_MODELS),
|
||||
headers=request_headers,
|
||||
)
|
||||
# An SDXL-baseline model rejects any control_type at validation, classic or extended alike.
|
||||
assert async_req.status_code == 400, async_req.get_data(as_text=True)
|
||||
assert async_req.get_json().get("rc") == "ControlNetMismatch", async_req.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_legacy_hough_alias_still_matches_old_bridge(client, request_headers: dict[str, str]) -> None:
|
||||
async_req = client.post("/api/v2/generate/async", json=_controlnet_async_dict("hough"), headers=request_headers)
|
||||
assert async_req.status_code < 400, async_req.get_data(as_text=True)
|
||||
req_id = async_req.get_json()["id"]
|
||||
|
||||
try:
|
||||
old_pop = client.post("/api/v2/generate/pop", json=_pop_dict(OLD_BRIDGE_AGENT), headers=request_headers)
|
||||
assert old_pop.status_code < 400, old_pop.get_data(as_text=True)
|
||||
old_results = old_pop.get_json()
|
||||
# `hough` is a classic control type, so an old controlnet worker still fulfils it.
|
||||
assert old_results["id"] is not None, old_results
|
||||
assert old_results["payload"]["control_type"] == "hough", old_results
|
||||
finally:
|
||||
client.delete(f"/api/v2/generate/status/{req_id}", headers=request_headers)
|
||||
@@ -0,0 +1,162 @@
|
||||
# SPDX-FileCopyrightText: 2026 Tazlin <tazlin@haidra.net>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Cross-repo wire-contract proof: live HTTP server parsed by the local horde_sdk.
|
||||
|
||||
The unit/integration suites exercise the server in isolation. This module proves
|
||||
the wire contract between the AI-Horde server tree and the horde_sdk tree: the
|
||||
server's real HTTP pop responses parse into the local horde_sdk response models
|
||||
with the new annotation/extended-controlnet fields intact, and the SDK's request
|
||||
models re-serialize those same fields. The SDK runs in its own venv as a
|
||||
subprocess so the two trees never share an interpreter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from horde.bridge_reference import EXTENDED_CONTROLNET_REGEN_VERSION
|
||||
|
||||
# The SDK-side interpreter is host-specific: point HORDE_SDK_WIRE_PYTHON at a python whose
|
||||
# environment has the horde_sdk under test installed; the suite skips when it is not set.
|
||||
SDK_PYTHON = Path(os.environ["HORDE_SDK_WIRE_PYTHON"]) if os.environ.get("HORDE_SDK_WIRE_PYTHON") else None
|
||||
SDK_CLIENT_SCRIPT = Path(__file__).resolve().parent / "_sdk_wire_client.py"
|
||||
|
||||
TEST_MODELS = ["stable_diffusion"]
|
||||
NEW_BRIDGE_AGENT = f"AI Horde Worker reGen:{EXTENDED_CONTROLNET_REGEN_VERSION}:https://github.com/Haidra-Org/horde-worker-reGen"
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.object_storage,
|
||||
pytest.mark.usefixtures("object_store_ready"),
|
||||
pytest.mark.skipif(
|
||||
SDK_PYTHON is None or not SDK_PYTHON.exists(),
|
||||
reason="HORDE_SDK_WIRE_PYTHON not set or does not exist",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _load_image_as_b64(image_path: str) -> str:
|
||||
final_src_img = Image.open(image_path)
|
||||
buffer = BytesIO()
|
||||
final_src_img.save(buffer, format="Webp", quality=50, exact=True)
|
||||
return base64.b64encode(buffer.getvalue()).decode("utf8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_server(app) -> Iterator[str]:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
probe.close()
|
||||
|
||||
server = make_server("127.0.0.1", port, app, threaded=True)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_sdk_parses_live_pop_responses(
|
||||
client,
|
||||
live_server: str,
|
||||
api_key: str,
|
||||
request_headers: dict[str, str],
|
||||
) -> None:
|
||||
annotation_async = {
|
||||
"forms": [{"name": "annotation", "payload": {"control_type": "canny"}}],
|
||||
"source_image": "https://github.com/Haidra-Org/AI-Horde/blob/main/icon.png?raw=true",
|
||||
}
|
||||
annotation_req = client.post("/api/v2/interrogate/async", json=annotation_async, headers=request_headers)
|
||||
assert annotation_req.status_code < 400, annotation_req.get_data(as_text=True)
|
||||
annotation_id = annotation_req.get_json()["id"]
|
||||
|
||||
image_async = {
|
||||
"prompt": "a controlnet-guided horde of robots",
|
||||
"nsfw": True,
|
||||
"censor_nsfw": False,
|
||||
"r2": True,
|
||||
"shared": True,
|
||||
"trusted_workers": True,
|
||||
"params": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"steps": 20,
|
||||
"cfg_scale": 7.5,
|
||||
"sampler_name": "k_euler_a",
|
||||
"control_type": "lineart",
|
||||
},
|
||||
"models": TEST_MODELS,
|
||||
"source_image": _load_image_as_b64("img_stable/0.jpg"),
|
||||
"source_processing": "img2img",
|
||||
}
|
||||
image_req = client.post("/api/v2/generate/async", json=image_async, headers=request_headers)
|
||||
assert image_req.status_code < 400, image_req.get_data(as_text=True)
|
||||
image_id = image_req.get_json()["id"]
|
||||
|
||||
cfg = {
|
||||
"base_url": live_server,
|
||||
"apikey": api_key,
|
||||
"client_agent": request_headers["Client-Agent"],
|
||||
"alchemy_pop_body": {
|
||||
"name": "CICD Fake Alchemist",
|
||||
"forms": ["annotation"],
|
||||
"annotation_types": ["canny"],
|
||||
"bridge_agent": request_headers["Client-Agent"],
|
||||
"max_tiles": 96,
|
||||
},
|
||||
"image_pop_body": {
|
||||
"name": "CICD Fake Dreamer",
|
||||
"models": TEST_MODELS,
|
||||
"bridge_agent": NEW_BRIDGE_AGENT,
|
||||
"nsfw": True,
|
||||
"amount": 10,
|
||||
"max_pixels": 4194304,
|
||||
"allow_img2img": True,
|
||||
"allow_painting": True,
|
||||
"allow_unsafe_ipaddr": True,
|
||||
"allow_post_processing": True,
|
||||
"allow_controlnet": True,
|
||||
"allow_extended_controlnet": True,
|
||||
"allow_sdxl_controlnet": True,
|
||||
"allow_lora": True,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(SDK_PYTHON), str(SDK_CLIENT_SCRIPT)],
|
||||
input=json.dumps(cfg),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(SDK_CLIENT_SCRIPT.parent),
|
||||
timeout=120,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"SDK wire client failed (rc={completed.returncode}).\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
|
||||
)
|
||||
verdict = json.loads(completed.stdout.strip().splitlines()[-1])
|
||||
assert verdict["ok"] is True, verdict
|
||||
assert verdict["alchemy"]["control_type"] == "canny", verdict
|
||||
assert verdict["alchemy"]["has_r2_upload"] is True, verdict
|
||||
assert verdict["alchemy"]["request_roundtrip_annotation_types"] == ["canny"], verdict
|
||||
assert verdict["image"]["control_type"] == "lineart", verdict
|
||||
assert verdict["image"]["request_roundtrip_allow_extended_controlnet"] is True, verdict
|
||||
finally:
|
||||
client.delete(f"/api/v2/interrogate/status/{annotation_id}", headers=request_headers)
|
||||
client.delete(f"/api/v2/generate/status/{image_id}", headers=request_headers)
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-FileCopyrightText: 2026 Tazlin <tazlin.on.github@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Capability gating in ``horde.bridge_reference.check_bridge_capability``.
|
||||
|
||||
The extended controlnet gate keeps image-generation jobs whose ``control_type``
|
||||
falls outside the classic set from being dispatched to bridge agents too old to
|
||||
annotate them. A regression here would either strand the new control types (no
|
||||
worker ever matches) or silently route them to workers that cannot render them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from horde.bridge_reference import EXTENDED_CONTROLNET_REGEN_VERSION, check_bridge_capability
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _regen_agent(version) -> str:
|
||||
return f"AI Horde Worker reGen:{version}:https://github.com/Haidra-Org/horde-worker-reGen"
|
||||
|
||||
|
||||
class TestExtendedControlnetCapability:
|
||||
def test_classic_controlnet_available_on_old_regen(self):
|
||||
# The classic controlnet capability predates the extended gate.
|
||||
assert check_bridge_capability("controlnet", _regen_agent(13)) is True
|
||||
|
||||
def test_extended_absent_below_threshold(self):
|
||||
agent = _regen_agent(EXTENDED_CONTROLNET_REGEN_VERSION - 1)
|
||||
assert check_bridge_capability("extended_controlnet", agent) is False
|
||||
|
||||
def test_extended_present_at_threshold(self):
|
||||
agent = _regen_agent(EXTENDED_CONTROLNET_REGEN_VERSION)
|
||||
assert check_bridge_capability("extended_controlnet", agent) is True
|
||||
|
||||
def test_extended_present_above_threshold(self):
|
||||
agent = _regen_agent(EXTENDED_CONTROLNET_REGEN_VERSION + 5)
|
||||
assert check_bridge_capability("extended_controlnet", agent) is True
|
||||
|
||||
def test_legacy_cpp_worker_never_gets_extended(self):
|
||||
# The legacy "AI Horde Worker" agent has no extended controlnet path at any version.
|
||||
assert check_bridge_capability("extended_controlnet", "AI Horde Worker:99:https://x") is False
|
||||
@@ -3,10 +3,19 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from horde.consts import (
|
||||
ANNOTATION_DETECTOR_KUDOS_BUCKETS,
|
||||
ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
ANNOTATION_KUDOS_DEFAULT_BUCKET,
|
||||
IMAGE_CONTROL_TYPES,
|
||||
KNOWN_CONTROL_TYPES,
|
||||
KNOWN_POST_PROCESSORS,
|
||||
KNOWN_SAMPLERS,
|
||||
KNOWN_UPSCALERS,
|
||||
LEGACY_IMAGE_CONTROL_TYPES,
|
||||
SECOND_ORDER_SAMPLERS,
|
||||
annotation_detector_kudos_bucket,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,3 +41,91 @@ class TestKnownUpscalers:
|
||||
def test_upscalers_are_post_processors(self):
|
||||
for upscaler in KNOWN_UPSCALERS:
|
||||
assert upscaler in KNOWN_POST_PROCESSORS, f"Upscaler '{upscaler}' not in KNOWN_POST_PROCESSORS"
|
||||
|
||||
|
||||
class TestImageControlTypes:
|
||||
def test_image_control_types_derive_from_unified_list(self):
|
||||
# The image-generation enum is the unified control set plus the legacy `hough` alias.
|
||||
assert IMAGE_CONTROL_TYPES == [*KNOWN_CONTROL_TYPES, "hough"]
|
||||
|
||||
def test_legacy_alias_hough_is_accepted(self):
|
||||
# Existing clients still send `hough` for the detector the unified list spells `mlsd`.
|
||||
assert "hough" in IMAGE_CONTROL_TYPES
|
||||
assert "mlsd" in IMAGE_CONTROL_TYPES
|
||||
|
||||
def test_new_types_are_accepted_by_image_enum(self):
|
||||
for new_type in ("lineart", "teed", "depth_anything_v2"):
|
||||
assert new_type in IMAGE_CONTROL_TYPES
|
||||
|
||||
def test_legacy_set_is_the_classic_nine(self):
|
||||
assert LEGACY_IMAGE_CONTROL_TYPES == [
|
||||
"canny",
|
||||
"hed",
|
||||
"depth",
|
||||
"normal",
|
||||
"openpose",
|
||||
"seg",
|
||||
"scribble",
|
||||
"fakescribbles",
|
||||
"hough",
|
||||
]
|
||||
|
||||
def test_legacy_types_are_all_dispatchable(self):
|
||||
# Every legacy type except the `hough` alias is present in the unified list verbatim.
|
||||
for legacy_type in LEGACY_IMAGE_CONTROL_TYPES:
|
||||
assert legacy_type in IMAGE_CONTROL_TYPES
|
||||
|
||||
def test_mlsd_is_an_extended_type(self):
|
||||
# `mlsd` uses a spelling old workers do not understand, so it gates as a new type.
|
||||
assert "mlsd" not in LEGACY_IMAGE_CONTROL_TYPES
|
||||
|
||||
|
||||
class TestAnnotationKudosBuckets:
|
||||
_VALID_BUCKETS = {
|
||||
ANNOTATION_KUDOS_BUCKET_WEIGHTLESS,
|
||||
ANNOTATION_KUDOS_BUCKET_WEIGHTED,
|
||||
ANNOTATION_KUDOS_BUCKET_HUB,
|
||||
}
|
||||
|
||||
def test_every_known_control_type_has_an_explicit_bucket(self):
|
||||
# Lockstep guard: a new control type added to KNOWN_CONTROL_TYPES must be assigned a
|
||||
# detector cost class explicitly. Silent defaulting is only for unlisted/novel types.
|
||||
missing = [ct for ct in KNOWN_CONTROL_TYPES if ct not in ANNOTATION_DETECTOR_KUDOS_BUCKETS]
|
||||
assert not missing, f"KNOWN_CONTROL_TYPES with no explicit kudos bucket: {missing}"
|
||||
|
||||
def test_bucket_map_has_no_unknown_control_types(self):
|
||||
# The mapping should not price detectors the server does not actually advertise.
|
||||
extra = [ct for ct in ANNOTATION_DETECTOR_KUDOS_BUCKETS if ct not in KNOWN_CONTROL_TYPES]
|
||||
assert not extra, f"Bucket map references non-KNOWN_CONTROL_TYPES: {extra}"
|
||||
|
||||
def test_all_buckets_are_one_of_the_three_classes(self):
|
||||
for control_type, bucket in ANNOTATION_DETECTOR_KUDOS_BUCKETS.items():
|
||||
assert bucket in self._VALID_BUCKETS, f"{control_type} has out-of-class bucket {bucket}"
|
||||
|
||||
def test_three_classes_are_strictly_ordered(self):
|
||||
assert ANNOTATION_KUDOS_BUCKET_WEIGHTLESS < ANNOTATION_KUDOS_BUCKET_WEIGHTED < ANNOTATION_KUDOS_BUCKET_HUB
|
||||
|
||||
def test_unknown_type_falls_back_to_middle_bucket(self):
|
||||
assert annotation_detector_kudos_bucket("not_a_real_detector_xyz") == ANNOTATION_KUDOS_DEFAULT_BUCKET
|
||||
assert ANNOTATION_KUDOS_DEFAULT_BUCKET == ANNOTATION_KUDOS_BUCKET_WEIGHTED
|
||||
|
||||
def test_missing_type_falls_back_to_middle_bucket(self):
|
||||
# A None control_type (no payload) must not raise and lands on the default.
|
||||
assert annotation_detector_kudos_bucket(None) == ANNOTATION_KUDOS_DEFAULT_BUCKET
|
||||
|
||||
def test_helper_agrees_with_mapping_for_known_types(self):
|
||||
for control_type, bucket in ANNOTATION_DETECTOR_KUDOS_BUCKETS.items():
|
||||
assert annotation_detector_kudos_bucket(control_type) == bucket
|
||||
|
||||
def test_hub_detector_prices_above_weightless_for_same_tiles(self):
|
||||
# Pricing uses the bucket: an oneformer (HUB) job pays more than a canny (WEIGHTLESS) job
|
||||
# for the same number of image tiles.
|
||||
image_tiles = 4
|
||||
canny_kudos = image_tiles * annotation_detector_kudos_bucket("canny")
|
||||
oneformer_kudos = image_tiles * annotation_detector_kudos_bucket("oneformer_ade20k")
|
||||
assert oneformer_kudos > canny_kudos
|
||||
|
||||
def test_representative_class_members(self):
|
||||
assert annotation_detector_kudos_bucket("canny") == ANNOTATION_KUDOS_BUCKET_WEIGHTLESS
|
||||
assert annotation_detector_kudos_bucket("openpose") == ANNOTATION_KUDOS_BUCKET_WEIGHTED
|
||||
assert annotation_detector_kudos_bucket("depth_anything_v2") == ANNOTATION_KUDOS_BUCKET_HUB
|
||||
@@ -211,3 +211,68 @@ class TestUnknownInputsHandled:
|
||||
img2img = dict(basis_payload, source_processing="img2img", source_image=True)
|
||||
# Same arithmetic path → same kudos.
|
||||
assert kudos_model.calculate_kudos(remix) == kudos_model.calculate_kudos(img2img)
|
||||
|
||||
|
||||
class TestControlTypeCanonicalMapping:
|
||||
"""The kudos checkpoint only has one-hot slots for the classic control types.
|
||||
|
||||
Unified control types are collapsed onto their closest classic slot before
|
||||
the lookup so the trained input vector keeps its length. These tests lock the
|
||||
vector-length invariant and the specific slot each new type lands on.
|
||||
|
||||
Comparisons use ``(a == b).all()`` rather than a framework-specific equality
|
||||
so they hold whether the feature vector is a torch tensor or a numpy array,
|
||||
and ``payload_to_tensor`` is the compatibility alias both backends keep.
|
||||
"""
|
||||
|
||||
def test_new_type_preserves_vector_length(self, kudos_model, basis_payload):
|
||||
from horde.classes.stable.kudos import KudosModel
|
||||
|
||||
classic = KudosModel.payload_to_tensor(dict(basis_payload, control_type="canny"))
|
||||
new_type = KudosModel.payload_to_tensor(dict(basis_payload, control_type="lineart"))
|
||||
# A changed width here means a new slot leaked into the one-hot, which the
|
||||
# frozen checkpoint cannot consume.
|
||||
assert new_type.shape == classic.shape
|
||||
|
||||
def test_lineart_family_lands_on_hed_slot(self, kudos_model, basis_payload):
|
||||
from horde.classes.stable.kudos import KudosModel
|
||||
|
||||
for lineart_type in ("lineart", "teed", "pidinet", "standard_lineart"):
|
||||
mapped = KudosModel.payload_to_tensor(dict(basis_payload, control_type=lineart_type))
|
||||
hed = KudosModel.payload_to_tensor(dict(basis_payload, control_type="hed"))
|
||||
assert bool((mapped == hed).all()), lineart_type
|
||||
|
||||
def test_cheap_preprocessors_land_on_canny_slot(self, kudos_model, basis_payload):
|
||||
from horde.classes.stable.kudos import KudosModel
|
||||
|
||||
for canny_type in ("pyracanny", "tile", "recolor_luminance", "shuffle"):
|
||||
mapped = KudosModel.payload_to_tensor(dict(basis_payload, control_type=canny_type))
|
||||
canny = KudosModel.payload_to_tensor(dict(basis_payload, control_type="canny"))
|
||||
assert bool((mapped == canny).all()), canny_type
|
||||
|
||||
def test_depth_family_lands_on_depth_slot(self, kudos_model, basis_payload):
|
||||
from horde.classes.stable.kudos import KudosModel
|
||||
|
||||
for depth_type in ("midas_depth", "zoe_depth", "depth_anything_v2"):
|
||||
mapped = KudosModel.payload_to_tensor(dict(basis_payload, control_type=depth_type))
|
||||
depth = KudosModel.payload_to_tensor(dict(basis_payload, control_type="depth"))
|
||||
assert bool((mapped == depth).all()), depth_type
|
||||
|
||||
def test_mlsd_maps_to_legacy_hough_slot(self, kudos_model, basis_payload):
|
||||
from horde.classes.stable.kudos import KudosModel
|
||||
|
||||
mlsd = KudosModel.payload_to_tensor(dict(basis_payload, control_type="mlsd"))
|
||||
hough = KudosModel.payload_to_tensor(dict(basis_payload, control_type="hough"))
|
||||
assert bool((mlsd == hough).all())
|
||||
|
||||
def test_unknown_type_falls_back_to_none_slot(self, kudos_model, basis_payload):
|
||||
from horde.classes.stable.kudos import KudosModel
|
||||
|
||||
# An unmapped, unknown type must not raise on the one-hot index lookup.
|
||||
unknown = KudosModel.payload_to_tensor(dict(basis_payload, control_type="not_a_real_detector_xyz"))
|
||||
neutral = KudosModel.payload_to_tensor(dict(basis_payload, control_type="None"))
|
||||
assert bool((unknown == neutral).all())
|
||||
|
||||
def test_new_type_prices_without_crashing(self, kudos_model, basis_payload):
|
||||
priced = kudos_model.calculate_kudos(dict(basis_payload, control_type="teed"))
|
||||
assert priced > 0
|
||||
Reference in new issue
Block a user