From de7959397c722fe8c00a5d34342156170f182413 Mon Sep 17 00:00:00 2001 From: vladmandic Date: Mon, 16 Mar 2026 10:32:12 +0100 Subject: [PATCH] fb merge phase 3 Signed-off-by: vladmandic --- extensions-builtin/sdnext-modernui | 2 +- installer.py | 2 +- launch.py | 2 + modules/api/api.py | 7 ++- modules/api/endpoints.py | 1 - modules/api/middleware.py | 13 +++-- modules/api/models.py | 4 +- modules/api/options.py | 94 ++++++++++++++++++++++++++++++ modules/api/server.py | 22 ------- modules/api/validate.py | 41 +++++++++++++ modules/postprocess/yolo.py | 6 +- modules/ui_definitions.py | 12 ++-- 12 files changed, 161 insertions(+), 45 deletions(-) create mode 100644 modules/api/options.py create mode 100644 modules/api/validate.py diff --git a/extensions-builtin/sdnext-modernui b/extensions-builtin/sdnext-modernui index f287be148..bdc35c473 160000 --- a/extensions-builtin/sdnext-modernui +++ b/extensions-builtin/sdnext-modernui @@ -1 +1 @@ -Subproject commit f287be148c8345913cba177982c4fb6e18ad70dc +Subproject commit bdc35c4731ee37e2191d52d3cf3ba6ccd37eecf8 diff --git a/installer.py b/installer.py index e6b1857e3..94f6592b9 100644 --- a/installer.py +++ b/installer.py @@ -468,7 +468,7 @@ def check_diffusers(): t_start = time.time() if args.skip_all: return - target_commit = '8ec0a5ccad96957c10388d2d2acc7fdd8e0fab84' # diffusers commit hash + target_commit = "e5aa719241f9b74d6700be3320a777799bfab70a" # diffusers commit hash # if args.use_rocm or args.use_zluda or args.use_directml: # sha = '043ab2520f6a19fce78e6e060a68dbc947edb9f9' # lock diffusers versions for now pkg = package_spec('diffusers') diff --git a/launch.py b/launch.py index 6aebe7385..7d44a75e0 100755 --- a/launch.py +++ b/launch.py @@ -319,6 +319,8 @@ def main(): if float(args.monitor) > 0 and t_current - t_monitor > float(args.monitor): log.trace(f'Monitor: {get_memory_stats(detailed=True)}') t_monitor = t_current + from modules.api.validate import get_stats + get_stats() if not alive: if uv is not None and uv.wants_restart: clean_server() diff --git a/modules/api/api.py b/modules/api/api.py index b844c8f87..efc3ba1c3 100644 --- a/modules/api/api.py +++ b/modules/api/api.py @@ -53,9 +53,6 @@ class Api: self.add_api_route("/sdapi/v1/skip", server.post_skip, methods=["POST"]) self.add_api_route("/sdapi/v1/shutdown", server.post_shutdown, methods=["POST"]) self.add_api_route("/sdapi/v1/memory", server.get_memory, methods=["GET"], response_model=models.ResMemory) - self.add_api_route("/sdapi/v1/options", server.get_config, methods=["GET"], response_model=models.OptionsModel) - self.add_api_route("/sdapi/v1/options", server.set_config, methods=["POST"]) - self.add_api_route("/sdapi/v1/options-info", server.get_options_info, methods=["GET"], tags=["Server"]) self.add_api_route("/sdapi/v1/cmd-flags", server.get_cmd_flags, methods=["GET"], response_model=models.FlagsModel) self.add_api_route("/sdapi/v1/gpu", gpu.get_gpu_status, methods=["GET"], response_model=list[models.ResGPU]) @@ -103,6 +100,10 @@ class Api: self.add_api_route("/sdapi/v1/modules", endpoints.get_modules, methods=["GET"], tags=["Functional"]) self.add_api_route("/sdapi/v1/sampler", endpoints.get_sampler, methods=["GET"], response_model=dict, tags=["Functional"]) + # options api + from modules.api import options + options.register_api(self.app) + # caption api from modules.api import caption caption.register_api() diff --git a/modules/api/endpoints.py b/modules/api/endpoints.py index f86c829d0..775fa1bc9 100644 --- a/modules/api/endpoints.py +++ b/modules/api/endpoints.py @@ -1,4 +1,3 @@ -from modules.logger import log from modules import shared from modules.api import models, helpers diff --git a/modules/api/middleware.py b/modules/api/middleware.py index 3ec324724..0e78959f5 100644 --- a/modules/api/middleware.py +++ b/modules/api/middleware.py @@ -12,7 +12,7 @@ from fastapi.exceptions import HTTPException from fastapi.encoders import jsonable_encoder from modules.logger import log import modules.errors as errors - +from modules.api.validate import validate_request errors.install() ignore_endpoints = [ @@ -43,24 +43,23 @@ def setup_middleware(app: FastAPI, cmd_opts): @app.middleware("http") async def api_preprocess(req: Request, call_next): - log.critical(f'HERE SCOPE: {req.scope}') - log.critical(f'HERE client: {req.client}') try: ts = time.time() res: Response = await call_next(req) duration = str(round(time.time() - ts, 4)) res.headers["X-Process-Time"] = duration endpoint = req.scope.get('path', 'err') + client = req.scope.get('client', ('0:0.0.0', 0))[0] token = req.cookies.get("access-token") or req.cookies.get("access-token-unsecure") + validate_request(client, endpoint) if (cmd_opts.api_log) and endpoint.startswith('/sdapi'): if any([endpoint.startswith(x) for x in ignore_endpoints]): # noqa C419 # pylint: disable=use-a-generator return res - log.info('API user={user} code={code} {prot}/{ver} {method} {endpoint} {cli} {host} {duration}'.format( # pylint: disable=consider-using-f-string, logging-format-interpolation + log.info('API user={user} code={code} {prot}/{ver} {method} {endpoint} {client} {duration}'.format( # pylint: disable=consider-using-f-string, logging-format-interpolation user = app.tokens.get(token) if hasattr(app, 'tokens') else None, code = res.status_code, ver = req.scope.get('http_version', '0.0'), - cli = req.scope.get('client', ('0:0.0.0', 0))[0], - host = req.client.host, + client = client, prot = req.scope.get('scheme', 'err'), method = req.scope.get('method', 'err'), endpoint = endpoint, @@ -84,6 +83,8 @@ def setup_middleware(app: FastAPI, cmd_opts): return JSONResponse(status_code=err['code'], content=jsonable_encoder(err)) if err['code'] == 404 and 'file=html/' in req.url.path: # dont spam with locales return JSONResponse(status_code=err['code'], content=jsonable_encoder(err)) + if err["code"] == 429: # dont spam with rate limit errors + return JSONResponse(status_code=err["code"], content=jsonable_encoder(err)) if not any([req.url.path.endswith(x) for x in ignore_endpoints]): # noqa C419 # pylint: disable=use-a-generator log.error(f"API error: {req.method}: {req.url} {err}") diff --git a/modules/api/models.py b/modules/api/models.py index 9afaee7ac..f51bd3ad9 100644 --- a/modules/api/models.py +++ b/modules/api/models.py @@ -558,9 +558,9 @@ def create_model_from_signature(func: Callable, model_name: str, base_model: typ extra = 'allow' if varkw else 'ignore' config = CustomConfig if base_model == BaseModel: - create_model_args = {'__config__': config} + create_model_args = {'__config__': config} else: - create_model_args = {'__base__': base_model} + create_model_args = {'__base__': base_model} model = create_model( model_name, diff --git a/modules/api/options.py b/modules/api/options.py new file mode 100644 index 000000000..1ce2f22de --- /dev/null +++ b/modules/api/options.py @@ -0,0 +1,94 @@ +from typing import Any +from modules.shared import opts + + +def get_options(): + options = {} + for k in opts.data.keys(): + if opts.data_labels.get(k) is not None: + options.update({k: opts.data.get(k, opts.data_labels.get(k).default)}) + else: + options.update({k: opts.data.get(k, None)}) + if "sd_lyco" in options: + del options["sd_lyco"] + if "sd_lora" in options: + del options["sd_lora"] + return options + +def set_options(req: dict[str, Any]): + updated = [] + for k, v in req.items(): + updated.append({k: opts.set(k, v)}) + opts.save() + return {"updated": updated} + +def get_options_info(): + """ + Return metadata for all application settings. + Returns every registered option with its label, section, type, default value, + component kind (slider, switch, dropdown, etc.), and component args (min/max/step/choices). + Used by alternative UIs to dynamically build a settings editor. + """ + import re + import gradio as gr + from modules.shared_legacy import LegacyOption + from modules.ui_components import DropdownEditable + component_map = { + gr.Slider: "slider", + gr.Checkbox: "switch", + gr.Radio: "radio", + gr.Dropdown: "dropdown", + gr.Textbox: "input", + gr.Number: "number", + gr.ColorPicker: "color", + gr.CheckboxGroup: "checkboxgroup", + gr.HTML: "separator", + } + options_info = {} + sections_seen = {} + for key, info in opts.data_labels.items(): + section_id = info.section[0] if info.section else None + section_title = info.section[1] if info.section and len(info.section) > 1 else "" + hidden = section_id is None or "hidden" in (section_id or "").lower() or "hidden" in section_title.lower() + if section_id and section_id not in sections_seen: + sections_seen[section_id] = {"id": section_id, "title": section_title, "hidden": hidden} + if hidden: + args = {} + else: + try: + args = info.component_args() if callable(info.component_args) else (info.component_args or {}) + except Exception: + args = {} + comp_name = component_map.get(info.component, "input") + if info.component is DropdownEditable: + comp_name = "dropdown" + elif info.component is None: + comp_name = "switch" if isinstance(info.default, bool) else "number" if isinstance(info.default, (int, float)) else "input" + visible = args.get("visible", True) and (comp_name == "separator" or len(info.label) > 2) + serializable_args = {} + for arg_key in ("minimum", "maximum", "step", "choices", "precision", "multiselect"): + if arg_key in args: + serializable_args[arg_key] = args[arg_key] + label = info.label + if comp_name == "separator" and not label and isinstance(info.default, str): + label = re.sub(r"<[^>]+>", "", info.default).strip() + options_info[key] = { + "label": label, + "section_id": section_id, + "section_title": section_title, + "visible": visible, + "hidden": hidden, + "type": "boolean" if isinstance(info.default, bool) else "number" if isinstance(info.default, (int, float)) else "array" if isinstance(info.default, list) else "string", + "component": comp_name, + "component_args": serializable_args, + "default": info.default, + "is_legacy": isinstance(info, LegacyOption), + "is_secret": getattr(info, "secret", False), + } + return {"options": options_info, "sections": list(sections_seen.values())} + + +def register_api(app): + app.add_api_route("/sdapi/v1/options", get_options, methods=["GET"], response_model=dict, tags=["Server"]) + app.add_api_route("/sdapi/v1/options", set_options, methods=["POST"], tags=["Server"]) + app.add_api_route("/sdapi/v1/options-info", get_options_info, methods=["GET"], tags=["Server"]) diff --git a/modules/api/server.py b/modules/api/server.py index e0b17777e..7c3448a36 100644 --- a/modules/api/server.py +++ b/modules/api/server.py @@ -1,6 +1,5 @@ import os import time -from typing import Any from fastapi import Request, Depends from fastapi.exceptions import HTTPException from fastapi.responses import FileResponse @@ -86,27 +85,6 @@ def post_log(req: models.ReqPostLog): log.error(f'UI: {req.error}') return {} - -def get_config(): - options = {} - for k in shared.opts.data.keys(): - if shared.opts.data_labels.get(k) is not None: - options.update({k: shared.opts.data.get(k, shared.opts.data_labels.get(k).default)}) - else: - options.update({k: shared.opts.data.get(k, None)}) - if 'sd_lyco' in options: - del options['sd_lyco'] - if 'sd_lora' in options: - del options['sd_lora'] - return options - -def set_config(req: dict[str, Any]): - updated = [] - for k, v in req.items(): - updated.append({ k: shared.opts.set(k, v) }) - shared.opts.save() - return { "updated": updated } - def get_cmd_flags(): return vars(shared.cmd_opts) diff --git a/modules/api/validate.py b/modules/api/validate.py new file mode 100644 index 000000000..2ee26e35e --- /dev/null +++ b/modules/api/validate.py @@ -0,0 +1,41 @@ +import re +import limits +from fastapi.exceptions import HTTPException +from modules.logger import log + + +requests_summary = {} +request_cost = { # value is cost, 0=not rate limited, 1=default, >1 more expensive + "/file": 0, + "/run/predict": 0, + "/sdapi/v1/browser/thumb": 0, + "/sdapi/v1/network/thumb": 0, + "/sdapi/v1/txt2img": 5, + "/sdapi/v1/img2img": 5, + "/sdapi/v1/control": 5, +} +backend = limits.storage.MemoryStorage() +strategy = limits.strategies.SlidingWindowCounterRateLimiter(backend) +limiter = limits.parse("300/minute") + + +def get_stats(): + for k, v in requests_summary.items(): + if v > 1: + log.trace(f'API stats: {k}={v}') + + +def rate_limit(key): + cost = request_cost.get(key, 1) + if not strategy.hit(limiter, key, cost=cost): + log.warning(f'API: key={key} rate limit exceeded') + raise HTTPException(status_code=429, detail=f'{key}: rate limit exceeded') + + +def validate_request(client, endpoint): + api = re.match(r"^[^?#&=]+", endpoint).group(0) + key = f"{client}:{api}" + if key not in requests_summary: + requests_summary[key] = 0 + requests_summary[key] += 1 + rate_limit(key) diff --git a/modules/postprocess/yolo.py b/modules/postprocess/yolo.py index ca0a41e4f..42a6c92ea 100644 --- a/modules/postprocess/yolo.py +++ b/modules/postprocess/yolo.py @@ -174,7 +174,7 @@ class YoloRestorer(Detailer): _max = get_opt(p, 'detailer_max_size') max_size = _max if _max is not None and 0 < _max <= 1 else 1 if x_size >= min_size and y_size >=min_size and x_size <= max_size and y_size <= max_size: - use_seg = segmentation if segmentation is not None else shared.opts.detailer_seg + use_seg = segmentation if segmentation is not None else shared.opts.detailer_segmentation if mask: if use_seg and seg is not None: masked = seg @@ -433,7 +433,7 @@ class YoloRestorer(Detailer): pc.negative_prompts = [pc.negative_prompt] pc.prompts, pc.network_data = extra_networks.parse_prompts(pc.prompts) extra_networks.activate(pc, pc.network_data) - log.debug(f'Detail: model="{i+1}:{name}" item={j+1}/{len(items)} box={item.box} label="{item.label}" score={item.score:.2f} seg={use_seg if use_seg is not None else shared.opts.detailer_seg} prompt="{pc.prompt}"') + log.debug(f'Detail: model="{i+1}:{name}" item={j+1}/{len(items)} box={item.box} label="{item.label}" score={item.score:.2f} seg={use_seg} prompt="{pc.prompt}"') pc.init_images = [image] pc.image_mask = [item.mask] pc.overlay_images = [] @@ -516,7 +516,7 @@ class YoloRestorer(Detailer): with gr.Row(): enabled = gr.Checkbox(label="Enable detailer pass", elem_id=f"{tab}_detailer_enabled", value=False) with gr.Row(): - seg = gr.Checkbox(label="Use segmentation", elem_id=f"{tab}_detailer_seg", value=shared.opts.detailer_seg, visible=True) + seg = gr.Checkbox(label="Use segmentation", elem_id=f"{tab}_detailer_seg", value=shared.opts.detailer_segmentation, visible=True) save = gr.Checkbox(label="Include detections", elem_id=f"{tab}_detailer_save", value=shared.opts.detailer_save, visible=True) with gr.Row(): merge = gr.Checkbox(label="Merge detailers", elem_id=f"{tab}_detailer_merge", value=shared.opts.detailer_merge, visible=True) diff --git a/modules/ui_definitions.py b/modules/ui_definitions.py index 3715f5939..86f2fd74c 100644 --- a/modules/ui_definitions.py +++ b/modules/ui_definitions.py @@ -18,34 +18,34 @@ options_templates = {} def list_checkpoint_titles(): - import modules.sd_models + import modules.sd_models # pylint: disable=redefined-outer-name return modules.sd_models.checkpoint_titles() def refresh_checkpoints(): - import modules.sd_models + import modules.sd_models # pylint: disable=redefined-outer-name return modules.sd_models.list_models() def refresh_vaes(): - import modules.sd_vae + import modules.sd_vae # pylint: disable=redefined-outer-name modules.sd_vae.refresh_vae_list() def refresh_upscalers(): - import modules.modelloader + import modules.modelloader # pylint: disable=redefined-outer-name modules.modelloader.load_upscalers() def list_samplers(): - import modules.sd_samplers + import modules.sd_samplers # pylint: disable=redefined-outer-name modules.sd_samplers.set_samplers() return modules.sd_samplers.all_samplers def get_openvino_device_list(): try: - import modules.intel.openvino + import modules.intel.openvino # pylint: disable=redefined-outer-name return modules.intel.openvino.get_device_list() except Exception: return []