From 418070d9820a3ce3b41531be2b96330c54d63057 Mon Sep 17 00:00:00 2001 From: Divided by Zer0 Date: Sat, 7 Dec 2024 17:20:40 +0100 Subject: [PATCH] feat: Shared keys embedded into styles (#476) * feat: assign shared keys to styles * doc: changelog * tests: add test for styles --- CHANGELOG.md | 12 +++ horde/apis/models/stable_v2.py | 17 ++++ horde/apis/models/v2.py | 14 ++++ horde/apis/v2/base.py | 50 +++++++++-- horde/apis/v2/kobold.py | 22 +++-- horde/apis/v2/stable.py | 23 +++-- horde/classes/base/style.py | 3 + horde/classes/base/user.py | 9 +- horde/consts.py | 2 +- horde/database/functions.py | 2 +- horde/exceptions.py | 2 + sql_statements/4.45.0.txt | 5 ++ sql_statements/4.45.0.txt.license | 3 + tests/test_image_styles.py | 134 ++++++++++++++++++++++++++++++ 14 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 sql_statements/4.45.0.txt create mode 100644 sql_statements/4.45.0.txt.license create mode 100644 tests/test_image_styles.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b645bf..1404676e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-or-later # Changelog +# 4.45.0 + +* Can now assign shared keys to styles. When a shared key is assigned to a style, if it is still valid (i.e. not expired and has kudos) + when that style is applied, it will use that shared key instead of the api key provided by the user. + This can allow someone to share a simple style name with their friends and allow them to use their higher priority. + + * Shared keys assigned to styles cannot be used in isolation. They can only be used as part of that style. + * A single shared key can be assigned to more than 1 style + * The shared key assigned to a style is always visible in the style description and therefore considered public information. +* People can now transfer kudos to shared keys. This works on both the web interface and the API. Simply provide the shared key ID instead of a username. + When a kudos is transferred to a shared key, the kudos is transferred to the shared key owner, and the shared key kudos is increased by the same amount if it isn't unlimited (-1) + # 4.44.3 * Fix image validation warnings being sent to the wrong requests diff --git a/horde/apis/models/stable_v2.py b/horde/apis/models/stable_v2.py index d6f53ac7..098e64e0 100644 --- a/horde/apis/models/stable_v2.py +++ b/horde/apis/models/stable_v2.py @@ -1047,6 +1047,13 @@ def __init__(self, api): "models": fields.List( fields.String(description="The models to use with this style.", min_length=1, example="stable_diffusion"), ), + "sharedkey": fields.String( + required=False, + min_length=36, + max_length=36, + description="The UUID of a shared key which will be used to fulfil this style when active.", + example="00000000-0000-0000-0000-000000000000", + ), }, ) self.patch_model_style = api.model( @@ -1099,6 +1106,13 @@ def __init__(self, api): "models": fields.List( fields.String(description="The models to use with this style.", min_length=1, example="stable_diffusion"), ), + "sharedkey": fields.String( + required=False, + min_length=36, + max_length=36, + description="The UUID of a shared key which will be used to fulfil this style when active.", + example="00000000-0000-0000-0000-000000000000", + ), }, ) self.input_model_style_example_post = api.model( @@ -1147,10 +1161,13 @@ def __init__(self, api): { "id": fields.String( description="The UUID of the style. Use this to use the style or retrieve its information in the future.", + min_length=36, + max_length=36, example="00000000-0000-0000-0000-000000000000", ), "use_count": fields.Integer(description="The amount of times this style has been used in generations."), "creator": fields.String(description="The alias of the user to whom this style belongs to.", example="db0#1"), "examples": fields.List(fields.Nested(self.response_model_style_example, skip_none=True)), + "shared_key": fields.Nested(self.response_model_sharedkey_details), }, ) diff --git a/horde/apis/models/v2.py b/horde/apis/models/v2.py index 7d94811b..b52097a0 100644 --- a/horde/apis/models/v2.py +++ b/horde/apis/models/v2.py @@ -356,6 +356,13 @@ def __init__(self): help="Tags describing this style. Can be used for style discovery.", location="json", ) + self.style_parser.add_argument( + "sharedkey", + type=str, + required=False, + help="The UUID of a shared key which will be used to generate with this style if active.", + location="json", + ) self.style_parser_patch = reqparse.RequestParser() self.style_parser_patch.add_argument( "apikey", @@ -428,6 +435,13 @@ def __init__(self): help="Tags describing this style. Can be used for style discovery.", location="json", ) + self.style_parser.add_argument( + "sharedkey", + type=str, + required=False, + help="The UUID of a shared key which will be used to generate with this style if active.", + location="json", + ) class Models: diff --git a/horde/apis/v2/base.py b/horde/apis/v2/base.py index c0980b44..3ab1f5ba 100644 --- a/horde/apis/v2/base.py +++ b/horde/apis/v2/base.py @@ -138,6 +138,8 @@ def post(self): if self.args.workers: self.workers = self.args.workers self.user = None + self.apikey = None + self.sharedkey = None self.user_ip = request.remote_addr # For now this is checked on validate() self.safe_ip = True @@ -182,9 +184,12 @@ def validate(self): if self.args.webhook and not self.args.webhook.startswith("https://"): raise e.BadRequest("webhooks need to point to an https endpoint.") with HORDE.app_context(): # TODO DOUBLE CHECK THIS - # logger.warning(datetime.utcnow()) - if self.args.apikey: - self.sharedkey = database.find_sharedkey(self.args.apikey) + # If this is set, it means we've already found an active shared key through an applied style. + if self.sharedkey: + self.user = self.sharedkey.user + logger.debug(f"Using style-specified shared key {self.sharedkey.id} from user #{self.user.id}") + elif self.apikey: + self.sharedkey = database.find_sharedkey(self.apikey) if self.sharedkey: is_valid, error_msg, rc = self.sharedkey.is_valid() if not is_valid: @@ -192,9 +197,14 @@ def validate(self): self.downgrade_wp_priority = True else: raise e.Forbidden(message=error_msg, rc=rc) + if not self.sharedkey.is_adhoc(): + raise e.Forbidden( + message="This shared key cannot be used as it has been assigned to specific styles only", + rc="SharedKeyAssignedStyles", + ) self.user = self.sharedkey.user if not self.user: - self.user = database.find_user_by_api_key(self.args.apikey) + self.user = database.find_user_by_api_key(self.apikey) # logger.warning(datetime.utcnow()) if not self.user: raise e.InvalidAPIKey("generation") @@ -356,6 +366,17 @@ def activate_waiting_prompt(self): kudos_adjustment=2 if self.style_kudos is True else 0, ) + def apply_style(self): + # If it reaches this method, we've already made sure self.args.style isn't empty. + self.existing_style = database.get_style_by_uuid(self.args.style) + if not self.existing_style: + self.existing_style = database.get_style_by_name(self.args.style) + if not self.existing_style: + raise e.ThingNotFound("Style", self.args.style) + # If there's an attached shared key to the style, and it's not empty or expired, we use it. + if self.existing_style.sharedkey and self.existing_style.sharedkey.is_valid()[0] is True: + self.sharedkey = self.existing_style.sharedkey + class SyncGenerate(GenerateTemplate): # @api.expect(parsers.generate_parser, models.input_model_request_generation, validate=True) @@ -3172,7 +3193,14 @@ def post(self): return def validate(self): - pass + self.sharedkey = None + if self.args.sharedkey: + self.sharedkey = database.find_sharedkey(self.args.sharedkey) + if self.sharedkey is None: + raise e.BadRequest("This shared key does not exist", "SharedKeyInvalid") + shared_key_validity = self.sharedkey.is_valid() + if shared_key_validity[0] is False: + raise e.BadRequest(shared_key_validity[1], shared_key_validity[2]) class SingleStyleTemplateGet(Resource): @@ -3250,6 +3278,9 @@ def patch(self, style_id): style_modified = True if len(self.tags) > 0: style_modified = True + if self.sharedkey is not None: + self.existing_style.sharedkey_id = self.sharedkey.id + style_modified = True if not style_modified: return { "id": self.existing_style.id, @@ -3265,7 +3296,14 @@ def patch(self, style_id): }, 200 def validate(self): - pass + self.sharedkey = None + if self.args.sharedkey: + self.shared_key = database.find_sharedkey(self.args.sharedkey) + if self.sharedkey is None: + raise e.BadRequest("This shared key does not exist", "SharedKeyInvalid") + shared_key_validity = self.sharedkey.is_valid() + if shared_key_validity[0] is False: + raise e.BadRequest(shared_key_validity[1], shared_key_validity[2]) def delete(self, style_id): self.args = parsers.apikey_parser.parse_args() diff --git a/horde/apis/v2/kobold.py b/horde/apis/v2/kobold.py index 9b956b3c..f3065ab3 100644 --- a/horde/apis/v2/kobold.py +++ b/horde/apis/v2/kobold.py @@ -109,7 +109,7 @@ def initiate_waiting_prompt(self): ipaddr=self.user_ip, safe_ip=True, client_agent=self.args["Client-Agent"], - sharedkey_id=self.args.apikey if self.sharedkey else None, + sharedkey_id=self.sharedkey.id if self.sharedkey else None, proxied_account=self.args["proxied_account"], webhook=self.args.webhook, ) @@ -157,8 +157,11 @@ def initiate_waiting_prompt(self): text_tokens=self.wp.max_length, ) if not is_in_limit: - self.wp.delete() - raise e.BadRequest(fail_message) + # If we are using the shared key assigned to a style, then we bypass the shared key requirements + # since its owner explicitly allowed to be used with a style exceeding them + if not (self.existing_style and self.existing_style.sharedkey and self.existing_style.sharedkey.id == self.sharedkey.id): + self.wp.delete() + raise e.BadRequest(fail_message) def get_size_too_big_message(self): return ( @@ -168,6 +171,7 @@ def get_size_too_big_message(self): def validate(self): self.prompt = self.args.prompt + self.apikey = self.args.apikey self.apply_style() super().validate() param_validator = ParamValidator(self.prompt, self.args.models, self.params, self.user) @@ -187,11 +191,8 @@ def get_hashed_params_dict(self): def apply_style(self): if self.args.style is None: return - self.existing_style = database.get_style_by_uuid(self.args.style) - if not self.existing_style: - self.existing_style = database.get_style_by_name(self.args.style) - if not self.existing_style: - raise e.ThingNotFound("Style", self.args.style) + # The super() ensures the common parts of applying a style + super().apply_style() if self.existing_style.style_type != "text": raise e.BadRequest("Image styles cannot be used on image requests", "StyleMismatch") if isinstance(self.existing_style, StyleCollection): @@ -495,6 +496,7 @@ class TextStyle(StyleTemplate): code=200, description="Lists text styles information", as_list=True, + skip_none=True, ) def get(self): """Retrieves information about all text styles @@ -562,6 +564,7 @@ def post(self): nsfw=self.args.nsfw, prompt=self.args.prompt, params=self.args.params if self.args.params is not None else {}, + sharedkey_id=self.sharedkey.id if self.sharedkey else None, ) new_style.create() new_style.set_models(self.models) @@ -573,6 +576,7 @@ def post(self): }, 200 def validate(self): + super().validate() if database.get_style_by_name(f"{self.user.get_unique_alias()}::style::{self.style_name}"): raise e.BadRequest( ( @@ -596,6 +600,7 @@ class SingleTextStyle(SingleStyleTemplate): code=200, description="Lists text styles information", as_list=False, + skip_none=True, ) def get(self, style_id): """Displays information about a single text style.""" @@ -666,6 +671,7 @@ class SingleImageStyleByName(SingleStyleTemplateGet): code=200, description="Lists text style information by name", as_list=False, + skip_none=True, ) def get(self, style_name): """Seeks a text style by name and displays its information.""" diff --git a/horde/apis/v2/stable.py b/horde/apis/v2/stable.py index 62610635..ebff5c3a 100644 --- a/horde/apis/v2/stable.py +++ b/horde/apis/v2/stable.py @@ -116,6 +116,7 @@ def get_size_too_big_message(self): def validate(self): self.prompt = self.args.prompt + self.apikey = self.args.apikey self.apply_style() super().validate() param_validator = ParamValidator(prompt=self.prompt, models=self.args.models, params=self.params, user=self.user) @@ -244,7 +245,7 @@ def initiate_waiting_prompt(self): r2=self.args.r2, shared=shared, client_agent=self.args["Client-Agent"], - sharedkey_id=self.args.apikey if self.sharedkey else None, + sharedkey_id=self.sharedkey.id if self.sharedkey else None, proxied_account=self.args["proxied_account"], disable_batching=self.args["disable_batching"], webhook=self.args.webhook, @@ -301,8 +302,11 @@ def initiate_waiting_prompt(self): image_steps=requested_steps, ) if not is_in_limit: - self.wp.delete() - raise e.BadRequest(fail_message) + # If we are using the shared key assigned to a style, then we bypass the shared key requirements + # since its owner explicitly allowed to be used with a style exceeding them + if not (self.existing_style and self.existing_style.sharedkey and self.existing_style.sharedkey.id == self.sharedkey.id): + self.wp.delete() + raise e.BadRequest(fail_message) def extrapolate_dry_run_kudos(self): self.wp.source_image = self.args.source_image @@ -363,11 +367,8 @@ def activate_waiting_prompt(self): def apply_style(self): if self.args.style is None: return - self.existing_style = database.get_style_by_uuid(self.args.style) - if not self.existing_style: - self.existing_style = database.get_style_by_name(self.args.style) - if not self.existing_style: - raise e.ThingNotFound("Style", self.args.style) + # The super() ensures the common parts of applying a style + super().apply_style() if self.existing_style.style_type != "image": raise e.BadRequest("Text styles cannot be used on image requests", "StyleMismatch") if isinstance(self.existing_style, StyleCollection): @@ -1374,6 +1375,7 @@ class ImageStyle(StyleTemplate): code=200, description="Lists image styles information", as_list=True, + skip_none=True, ) def get(self): """Retrieves information about all image styles @@ -1441,6 +1443,7 @@ def post(self): nsfw=self.args.nsfw, prompt=self.args.prompt, params=self.args.params if self.args.params is not None else {}, + sharedkey_id=self.sharedkey.id if self.sharedkey else None, ) new_style.create() new_style.set_models(self.models) @@ -1452,6 +1455,7 @@ def post(self): }, 200 def validate(self): + super().validate() if database.get_style_by_name(f"{self.user.get_unique_alias()}::style::{self.style_name}"): raise e.BadRequest( ( @@ -1475,6 +1479,7 @@ class SingleImageStyle(SingleStyleTemplate): code=200, description="Lists image styles information", as_list=False, + skip_none=True, ) def get(self, style_id): """Displays information about an image style.""" @@ -1502,6 +1507,7 @@ def patch(self, style_id): return super().patch(style_id) def validate(self): + super().validate() if ( self.style_name is not None and database.get_style_by_name(f"{self.user.get_unique_alias()}::style::{self.style_name}") @@ -1545,6 +1551,7 @@ class SingleImageStyleByName(SingleStyleTemplateGet): code=200, description="Lists image style information by name", as_list=False, + skip_none=True, ) def get(self, style_name): """Seeks an image style by name and displays its information.""" diff --git a/horde/classes/base/style.py b/horde/classes/base/style.py index 8c524adf..4806ba3a 100644 --- a/horde/classes/base/style.py +++ b/horde/classes/base/style.py @@ -151,6 +151,8 @@ class Style(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user = db.relationship("User", back_populates="styles") + sharedkey_id = db.Column(uuid_column_type(), db.ForeignKey("user_sharedkeys.id"), nullable=True) + sharedkey = db.relationship("UserSharedKey", back_populates="styles") collections: Mapped[list[StyleCollection]] = db.relationship(secondary="style_collection_mapping", back_populates="styles") models = db.relationship("StyleModel", back_populates="style", cascade="all, delete-orphan") tags = db.relationship("StyleTag", back_populates="style", cascade="all, delete-orphan") @@ -206,6 +208,7 @@ def get_details(self, details_privilege=0): "use_count": self.use_count, "public": self.public, "nsfw": self.nsfw, + "shared_key": self.sharedkey.get_details() if self.sharedkey else None, } return ret_dict diff --git a/horde/classes/base/user.py b/horde/classes/base/user.py index e7f49b8c..be52c691 100644 --- a/horde/classes/base/user.py +++ b/horde/classes/base/user.py @@ -143,6 +143,7 @@ class UserSharedKey(db.Model): passive_deletes=True, cascade="all, delete-orphan", ) + styles = db.relationship("Style", back_populates="sharedkey") max_image_pixels = db.Column(db.Integer, default=-1, nullable=False) max_image_steps = db.Column(db.Integer, default=-1, nullable=False) max_text_tokens = db.Column(db.Integer, default=-1, nullable=False) @@ -183,9 +184,11 @@ def is_valid(self): def is_expired(self) -> bool: """Returns true if the key has expired""" - if self.expiry is not None and self.expiry < datetime.utcnow(): - return True - return False + return self.expiry is not None and self.expiry < datetime.utcnow() + + def is_adhoc(self) -> bool: + """Returns true if the key is not assigned to any styles""" + return len(self.styles) == 0 def is_job_within_limits( self, diff --git a/horde/consts.py b/horde/consts.py index 81cfea97..ef99e6af 100644 --- a/horde/consts.py +++ b/horde/consts.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -HORDE_VERSION = "4.44.3" +HORDE_VERSION = "4.45.0" HORDE_API_VERSION = "2.5" WHITELISTED_SERVICE_IPS = { diff --git a/horde/database/functions.py b/horde/database/functions.py index 604165bb..9719eb07 100644 --- a/horde/database/functions.py +++ b/horde/database/functions.py @@ -486,7 +486,7 @@ def transfer_kudos_to_username(source_user, dest_username, amount): if dest_user == source_user: return [0, "Cannot send kudos to yourself, ya monkey!", "KudosTransferToSelf"] kudos = transfer_kudos(source_user, dest_user, amount) - if kudos[0] > 0 and shared_key is not None: + if kudos[0] > 0 and shared_key is not None and shared_key.kudos != -1: shared_key.kudos += kudos[0] db.session.commit() return kudos diff --git a/horde/exceptions.py b/horde/exceptions.py index a95bcc9d..771bddf4 100644 --- a/horde/exceptions.py +++ b/horde/exceptions.py @@ -79,9 +79,11 @@ "TimeoutIP", "TooManyNewIPs", "KudosUpfront", + "SharedKeyInvalid", "SharedKeyEmpty", "SharedKeyExpired", "SharedKeyInsufficientKudos", + "SharedKeyAssignedStyles", "InvalidJobID", "RequestNotFound", "WorkerNotFound", diff --git a/sql_statements/4.45.0.txt b/sql_statements/4.45.0.txt new file mode 100644 index 00000000..e74c44da --- /dev/null +++ b/sql_statements/4.45.0.txt @@ -0,0 +1,5 @@ +ALTER TABLE styles +ADD COLUMN sharedkey_id UUID, +ADD CONSTRAINT styles_sharedkey_id_fkey +FOREIGN KEY (sharedkey_id) +REFERENCES user_sharedkeys(id); diff --git a/sql_statements/4.45.0.txt.license b/sql_statements/4.45.0.txt.license new file mode 100644 index 00000000..8140c6e2 --- /dev/null +++ b/sql_statements/4.45.0.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Konstantinos Thoukydidis + +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/tests/test_image_styles.py b/tests/test_image_styles.py new file mode 100644 index 00000000..3ddf0445 --- /dev/null +++ b/tests/test_image_styles.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2022 Konstantinos Thoukydidis +# +# SPDX-License-Identifier: AGPL-3.0-or-later + + +import requests + +TEST_MODELS = ["Fustercluck", "AlbedoBase XL (SDXL)"] + + +def test_styled_image_gen(api_key: str, HORDE_URL: str, CIVERSION: str) -> None: + print("test_styled_image_gen") + headers = {"apikey": api_key, "Client-Agent": f"aihorde_ci_client:{CIVERSION}:(discord)db0#1625"} # ci/cd user + style_dict = { + "name": "impasto test", + "info": "impasto test", + "public": True, + "prompt": "{p}, impasto impressionism###no blur, {np}", + "nsfw": False, + "params": { + "width": 1024, + "height": 512, + "steps": 8, + "cfg_scale": 7, + "sampler_name": "k_euler_a", + }, + "models": TEST_MODELS, + "loras": [{"name": "247778", "is_version": True}], + } + protocol = "http" + if HORDE_URL in ["dev.stablehorde.net", "stablehorde.net"]: + protocol = "https" + style_req = requests.post(f"{protocol}://{HORDE_URL}/api/v2/styles/image", json=style_dict, headers=headers) + assert style_req.ok, style_req.text + style_results = style_req.json() + style_id = style_results["id"] + + try: + async_dict = { + "prompt": "a horde of cute stable robots in a sprawling server room repairing a massive mainframe###organic", + "nsfw": True, + "censor_nsfw": False, + "r2": True, + "shared": True, + "trusted_workers": True, + "params": { + "width": 1024, + "height": 1024, + "steps": 8, + "cfg_scale": 1.5, + "sampler_name": "k_euler", + }, + "models": ["stable_diffusion"], + "style": style_id, + } + + async_req = requests.post(f"{protocol}://{HORDE_URL}/api/v2/generate/async", json=async_dict, headers=headers) + assert async_req.ok, async_req.text + async_results = async_req.json() + req_id = async_results["id"] + # print(async_results) + pop_dict = { + "name": "CICD Fake Dreamer", + "models": TEST_MODELS, + "bridge_agent": "AI Horde Worker reGen:9.0.1-citests:https://github.com/Haidra-Org/horde-worker-reGen", + "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_sdxl_controlnet": True, + "allow_lora": True, + } + pop_req = requests.post(f"{protocol}://{HORDE_URL}/api/v2/generate/pop", json=pop_dict, headers=headers) + try: + # print(pop_req.text) + assert pop_req.ok, pop_req.text + except AssertionError as err: + requests.delete(f"{protocol}://{HORDE_URL}/api/v2/generate/status/{req_id}", headers=headers) + print("Request cancelled") + raise err + pop_results = pop_req.json() + # print(json.dumps(pop_results, indent=4)) + + job_id = pop_results["id"] + try: + assert job_id is not None, pop_results + assert pop_results["payload"]["sampler_name"] == "k_euler_a" + assert pop_results["payload"]["width"] == 1024 + assert pop_results["payload"]["height"] == 512 + assert pop_results["payload"]["prompt"] == ( + "a horde of cute stable robots in a sprawling server room repairing a massive mainframe, impasto impressionism" + "###no blur, organic" + ) + except AssertionError as err: + requests.delete(f"{protocol}://{HORDE_URL}/api/v2/generate/status/{req_id}", headers=headers) + print("Request cancelled") + raise err + submit_dict = { + "id": job_id, + "generation": "R2", + "state": "ok", + "seed": 0, + } + submit_req = requests.post(f"{protocol}://{HORDE_URL}/api/v2/generate/submit", json=submit_dict, headers=headers) + assert submit_req.ok, submit_req.text + submit_results = submit_req.json() + assert submit_results["reward"] > 0 + retrieve_req = requests.get(f"{protocol}://{HORDE_URL}/api/v2/generate/status/{req_id}", headers=headers) + assert retrieve_req.ok, retrieve_req.text + retrieve_results = retrieve_req.json() + # print(json.dumps(retrieve_results, indent=4)) + assert len(retrieve_results["generations"]) == 1 + gen = retrieve_results["generations"][0] + assert len(gen["gen_metadata"]) == 0 + assert gen["seed"] == "0" + assert gen["worker_name"] == "CICD Fake Dreamer" + assert gen["model"] in TEST_MODELS + assert gen["state"] == "ok" + assert retrieve_results["kudos"] > 1 + assert retrieve_results["done"] is True + requests.delete(f"{protocol}://{HORDE_URL}/api/v2/generate/status/{req_id}", headers=headers) + except AssertionError as err: + requests.delete(f"{protocol}://{HORDE_URL}/api/v2/styles/image/{style_id}", headers=headers) + raise err + requests.delete(f"{protocol}://{HORDE_URL}/api/v2/styles/image/{style_id}", headers=headers) + + +if __name__ == "__main__": + # "ci/cd#12285" + test_styled_image_gen("2bc5XkMeLAWiN9O5s7bhfg", "dev.stablehorde.net", "0.2.0")