sdmux 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sdmux/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ sdmux: one client for every local image generation backend, with checkpoint identification built in.
3
+
4
+ import sdmux
5
+
6
+ sd = sdmux.connect("http://127.0.0.1:8188") # detects ComfyUI, A1111, Forge, SD.Next, SwarmUI or InvokeAI
7
+ r = sd.txt2img("1girl, smile <lora:detail:0.6>", model="waiIllustrious", steps=28, cfg=6,
8
+ sampler="Euler a", scheduler="Karras", seed=42, clip_skip=2)
9
+ r.image.save("out.png")
10
+
11
+ info = sdmux.identify("model.safetensors") # reads only the header
12
+ info.architecture, info.family, info.profile
13
+ """
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ from .errors import AuthRefused, Cancelled, NotReady, Rejected, SdmuxError, TimedOut, Unreachable, Unsupported # noqa: E402
18
+ from .transport import Options # noqa: E402
19
+ from .types import Model, Progress, Request, Result # noqa: E402
20
+ from .comfygraph import Graph, GraphError, NodeSpec # noqa: E402
21
+
22
+
23
+ def connect(url: str, kind: str | None = None, options: Options | None = None, defaults: str = "profile",
24
+ **option_overrides):
25
+ """
26
+ Connect to the backend at url and return its client.
27
+
28
+ kind skips detection ("a1111", "forge", "reforge", "sdnext", "comfyui", "swarmui", "invokeai").
29
+ Connection options: username, password, token, headers, connect_timeout, timeout, job_timeout,
30
+ retries, verify_tls, poll_interval. Credentials in the URL (https://user:pass@host) work too.
31
+
32
+ defaults: "profile" (default) fills settings a call leaves out from the model's profile; "fixed" uses
33
+ sdmux's fixed size/steps/cfg and leaves sampler, scheduler and clip skip to the server.
34
+ """
35
+ from .backends import BACKENDS
36
+ from .probe import detect
37
+
38
+ if kind is None:
39
+ kind = detect(url, options, **option_overrides).kind
40
+ try:
41
+ cls = BACKENDS[kind.lower()]
42
+ except KeyError:
43
+ raise ValueError(f"Unknown backend kind {kind!r}. Known: {', '.join(sorted(BACKENDS))}") from None
44
+ return cls(url, options, defaults=defaults, **option_overrides)
45
+
46
+
47
+ def detect(url: str, **option_overrides):
48
+ """Which backend answers at url. Returns a Detection (kind, evidence, version, tried)."""
49
+ from .probe import detect as _detect
50
+
51
+ return _detect(url, **option_overrides)
52
+
53
+
54
+ def identify(path, online: bool = False, **kwargs):
55
+ """Identify a model file from its header (and sidecars, metadata, name; Civitai when online=True)."""
56
+ from .modelinfo import identify as _identify
57
+
58
+ return _identify(path, online=online, **kwargs)
59
+
60
+
61
+ __all__ = [
62
+ "connect", "detect", "identify", "Options", "Graph", "GraphError", "NodeSpec", "Request", "Result", "Progress", "Model",
63
+ "SdmuxError", "Unreachable", "TimedOut", "AuthRefused", "NotReady", "Rejected", "Cancelled", "Unsupported",
64
+ "__version__",
65
+ ]
@@ -0,0 +1,17 @@
1
+ from .a1111 import A1111, Forge, SDNext
2
+ from .base import Backend
3
+ from .comfyui import ComfyUI
4
+ from .invokeai import InvokeAI
5
+ from .swarmui import SwarmUI
6
+
7
+ BACKENDS: dict[str, type[Backend]] = {
8
+ "a1111": A1111,
9
+ "forge": Forge,
10
+ "reforge": Forge,
11
+ "sdnext": SDNext,
12
+ "comfyui": ComfyUI,
13
+ "swarmui": SwarmUI,
14
+ "invokeai": InvokeAI,
15
+ }
16
+
17
+ __all__ = ["Backend", "A1111", "Forge", "SDNext", "ComfyUI", "SwarmUI", "InvokeAI", "BACKENDS"]
@@ -0,0 +1,308 @@
1
+ """
2
+ AUTOMATIC1111, Forge / reForge and SD.Next, through the /sdapi/v1 API (A1111 and Forge need --api).
3
+
4
+ Native escape hatch: api(path, payload) posts any body to any /sdapi/v1 route, and
5
+ generate() merges Request.extra into the payload, so every field the WebUI accepts
6
+ (alwayson_scripts, hires-fix, refiner, ...) is reachable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+
14
+ from ..errors import Cancelled, Rejected, SdmuxError
15
+ from ..names import canonical_scheduler, extract_loras, lora_tags, pick_native, sampler_id
16
+ from ..types import IMG2IMG, INPAINT, TXT2IMG, Model, Progress, Request, Result, load_image, to_b64
17
+ from .base import Backend
18
+
19
+
20
+ class A1111(Backend):
21
+ kind = "a1111"
22
+ label = "AUTOMATIC1111"
23
+ default_url = "http://127.0.0.1:7860"
24
+
25
+ #: When False (sdmux's choice), a model chosen with model= stays loaded after the call, like picking it
26
+ #: in the UI. A1111's own default (True) switches back after every call, reloading weights twice.
27
+ restore_settings = False
28
+
29
+ def __init__(self, url=None, options=None, **kw) -> None:
30
+ super().__init__(url, options, **kw)
31
+ self._schedulers: list[str] | None = None
32
+
33
+ # Listings -------------------------------------------------------------
34
+ def models(self) -> list[Model]:
35
+ return [
36
+ Model(
37
+ name=m.get("model_name") or m.get("title"),
38
+ title=m.get("title"),
39
+ path=m.get("filename"),
40
+ hash=m.get("hash"),
41
+ sha256=m.get("sha256"),
42
+ raw=m,
43
+ )
44
+ for m in self.http.get_json("/sdapi/v1/sd-models")
45
+ ]
46
+
47
+ def loras(self) -> list[Model]:
48
+ response = self.http.request("GET", "/sdapi/v1/loras")
49
+ if response.status_code == 404:
50
+ return []
51
+ items = self.http.json(self.http.check(response, "GET /sdapi/v1/loras"))
52
+ loras = []
53
+ for lora in items:
54
+ metadata = lora.get("metadata") or {}
55
+ loras.append(
56
+ Model(
57
+ name=lora.get("name") or lora.get("alias"),
58
+ title=lora.get("alias"),
59
+ path=lora.get("path"),
60
+ sha256=metadata.get("sshs_model_hash") if isinstance(metadata, dict) else None,
61
+ kind="lora",
62
+ raw=lora,
63
+ )
64
+ )
65
+ return loras
66
+
67
+ def samplers(self) -> list[str]:
68
+ return [s["name"] for s in self.http.get_json("/sdapi/v1/samplers")]
69
+
70
+ def schedulers(self) -> list[str]:
71
+ """Scheduler labels. [] where there is no /schedulers route (A1111 before 1.10, SD.Next)."""
72
+ if self._schedulers is None:
73
+ response = self.http.request("GET", "/sdapi/v1/schedulers")
74
+ if response.status_code >= 400:
75
+ self._schedulers = [] # 404 before A1111 1.10
76
+ else:
77
+ items = self.http.json(self.http.check(response, "GET /sdapi/v1/schedulers"))
78
+ self._schedulers = [s.get("label") or s.get("name") for s in items]
79
+ return list(self._schedulers)
80
+
81
+ def info(self) -> dict:
82
+ info = {"backend": self.kind, "url": self.url}
83
+ response = self.http.request("GET", "/sdapi/v1/version", timeout=10)
84
+ if response.status_code == 200:
85
+ try:
86
+ info["version"] = response.json()
87
+ except ValueError:
88
+ pass
89
+ try:
90
+ options = self.http.get_json("/sdapi/v1/options", timeout=10)
91
+ info["model"] = options.get("sd_model_checkpoint")
92
+ except Rejected:
93
+ pass
94
+ return info
95
+
96
+ def current_model(self) -> str | None:
97
+ """The checkpoint loaded on the server, which a request without model= runs on."""
98
+ try:
99
+ return self.http.get_json("/sdapi/v1/options", timeout=30).get("sd_model_checkpoint") or None
100
+ except SdmuxError:
101
+ return None
102
+
103
+ # Native ---------------------------------------------------------------
104
+ def api(self, path: str, payload: dict | None = None, method: str = "POST", timeout: float | None = None):
105
+ """Call any /sdapi/v1 route (path may omit the prefix) and return its JSON."""
106
+ if not path.startswith("/"):
107
+ path = "/sdapi/v1/" + path
108
+ response = self.http.request(method, path, json=payload, timeout=timeout)
109
+ return self.http.json(self.http.check(response, f"{method} {path}"))
110
+
111
+ # Generation -----------------------------------------------------------
112
+ def _sampler_fields(self, request: Request) -> dict:
113
+ from ..names import canonical_sampler
114
+
115
+ fields = {}
116
+ folded = None
117
+ if request.sampler:
118
+ _, folded = canonical_sampler(request.sampler)
119
+ native = pick_native(request.sampler, self.samplers(), sampler_id)
120
+ fields["sampler_name"] = native or request.sampler
121
+ scheduler = request.scheduler or folded
122
+ if scheduler:
123
+ schedulers = self.schedulers()
124
+ if schedulers:
125
+ fields["scheduler"] = pick_native(scheduler, schedulers, canonical_scheduler) or scheduler
126
+ elif request.sampler:
127
+ # No scheduler field (old A1111): A1111 reads a scheduler glued to the sampler name.
128
+ if canonical_scheduler(scheduler) not in ("automatic", "normal") and "sampler_name" in fields:
129
+ from ..names import a1111_scheduler_label
130
+
131
+ fields["sampler_name"] = f"{fields['sampler_name']} {a1111_scheduler_label(scheduler)}"
132
+ return fields
133
+
134
+ def build_payload(self, request: Request) -> dict:
135
+ """The /sdapi/v1/txt2img or /img2img body for a request (LoRA tags stay in the prompt)."""
136
+ prompt, loras = extract_loras(request.prompt)
137
+ if loras:
138
+ available = self.loras()
139
+ _, matched = self.resolve_loras(request.prompt, available)
140
+ prompt = " ".join(p for p in (prompt, lora_tags([(self.lora_name(m), w) for m, w in matched])) if p)
141
+ overrides: dict = {}
142
+ if request.model:
143
+ overrides["sd_model_checkpoint"] = self.resolve_model(request.model).title or request.model
144
+ if request.clip_skip:
145
+ overrides["CLIP_stop_at_last_layers"] = int(request.clip_skip)
146
+ if request.vae:
147
+ overrides["sd_vae"] = request.vae
148
+ payload = {
149
+ "prompt": prompt,
150
+ "negative_prompt": request.negative,
151
+ "width": request.width,
152
+ "height": request.height,
153
+ "steps": request.steps,
154
+ "cfg_scale": request.cfg,
155
+ "seed": request.seed if request.seed is not None else -1,
156
+ "batch_size": request.batch_size,
157
+ "n_iter": 1,
158
+ "send_images": True,
159
+ "save_images": False,
160
+ }
161
+ sampler_fields = self._sampler_fields(request)
162
+ overrides.update(sampler_fields.pop("override_settings", {}))
163
+ payload.update(sampler_fields)
164
+ if overrides:
165
+ payload["override_settings"] = overrides
166
+ payload["override_settings_restore_afterwards"] = self.restore_settings
167
+ if request.mode in (IMG2IMG, INPAINT):
168
+ payload["init_images"] = [to_b64(request.image.convert("RGB"))]
169
+ payload["denoising_strength"] = request.denoise
170
+ payload["resize_mode"] = 0
171
+ if request.mode == INPAINT:
172
+ payload.update(
173
+ {
174
+ "mask": to_b64(request.mask.convert("L")),
175
+ "mask_blur": request.mask_blur,
176
+ "inpainting_fill": 1, # start from the original pixels under the mask
177
+ "inpaint_full_res": request.inpaint_full_res,
178
+ "inpaint_full_res_padding": request.inpaint_padding,
179
+ "inpainting_mask_invert": 0, # white = repaint
180
+ }
181
+ )
182
+ payload.update(request.extra or {})
183
+ return payload
184
+
185
+ def _generate(self, request: Request) -> Result:
186
+ payload = self.build_payload(request)
187
+ route = "/sdapi/v1/txt2img" if request.mode == TXT2IMG else "/sdapi/v1/img2img"
188
+ with self._lock:
189
+ response = self.http.request("POST", route, json=payload, timeout=self.options.job_timeout)
190
+ if response.status_code >= 400:
191
+ raise Rejected(f"{self.label} rejected the request ({response.status_code}): {_error_text(response)}",
192
+ backend=self.kind, details=response.text)
193
+ body = self.http.json(response)
194
+ images_b64 = body.get("images") or []
195
+ try:
196
+ info = json.loads(body.get("info") or "{}")
197
+ except (TypeError, ValueError):
198
+ info = {}
199
+ if not images_b64:
200
+ if self._cancel.is_set() or info.get("interrupted"):
201
+ raise Cancelled("Interrupted.", backend=self.kind)
202
+ raise Rejected(f"{self.label} returned no image.", backend=self.kind, details=body)
203
+ infotexts = info.get("infotexts") or []
204
+ seeds = info.get("all_seeds") or ([info["seed"]] if "seed" in info else [])
205
+ images = []
206
+ # Extensions may append extra images (masks, grids) beyond the batch; keep only the batch.
207
+ for index, data in enumerate(images_b64[: max(request.batch_size, 1)]):
208
+ image = load_image(data)
209
+ if index < len(infotexts):
210
+ image.info["parameters"] = infotexts[index]
211
+ images.append(image)
212
+ if self._cancel.is_set():
213
+ # A1111 answers an interrupted job with the partially denoised image; it is kept in details.
214
+ raise Cancelled("Interrupted.", backend=self.kind, details={"partial_images": images, "info": info})
215
+ return Result(
216
+ images=images,
217
+ seed=seeds[0] if seeds else info.get("seed"),
218
+ seeds=list(seeds),
219
+ parameters=infotexts[0] if infotexts else "",
220
+ backend=self.kind,
221
+ raw={"info": info, "parameters": body.get("parameters")},
222
+ )
223
+
224
+ # Control --------------------------------------------------------------
225
+ def progress(self) -> Progress:
226
+ r = self.http.get_json("/sdapi/v1/progress", params={"skip_current_image": "true"}, timeout=10)
227
+ state = r.get("state") or {}
228
+ active = bool(state.get("job")) or bool(state.get("job_count"))
229
+ step, steps = state.get("sampling_step") or 0, state.get("sampling_steps") or 0
230
+ eta = r.get("eta_relative")
231
+ return Progress(
232
+ fraction=float(r.get("progress") or 0.0) if active else 0.0,
233
+ step=step if active else None,
234
+ steps=steps if active else None,
235
+ eta=round(eta, 1) if active and eta and eta > 0 else None,
236
+ active=active,
237
+ )
238
+
239
+ def interrupt(self) -> None:
240
+ self._cancel.set()
241
+ self.http.check(self.http.request("POST", "/sdapi/v1/interrupt", timeout=10), "interrupt")
242
+
243
+
244
+ def _error_text(response) -> str:
245
+ try:
246
+ body = response.json()
247
+ except ValueError:
248
+ return response.text[:1000]
249
+ if isinstance(body, dict):
250
+ return str(body.get("errors") or body.get("detail") or body.get("error") or body)[:1000]
251
+ return str(body)[:1000]
252
+
253
+
254
+ class Forge(A1111):
255
+ """Stable Diffusion WebUI Forge and reForge: the A1111 API with different built-in scripts."""
256
+
257
+ kind = "forge"
258
+ label = "Forge"
259
+
260
+
261
+ class SDNext(A1111):
262
+ """
263
+ SD.Next: the same /sdapi/v1 routes; it serves the API without --api.
264
+
265
+ SD.Next runs diffusers schedulers: the sampler list holds them all, and the A1111 scheduler is
266
+ approximated by its "Sigma algorithm" setting (schedulers_sigma: default, karras, exponential,
267
+ betas, lambdas, flowmatch). Its /sdapi/v1/schedulers lists samplers, not schedulers.
268
+ """
269
+
270
+ kind = "sdnext"
271
+ label = "SD.Next"
272
+ SIGMAS = ("default", "karras", "betas", "exponential", "lambdas", "flowmatch")
273
+
274
+ def schedulers(self) -> list[str]:
275
+ return list(self.SIGMAS)
276
+
277
+ def _sampler_fields(self, request: Request) -> dict:
278
+ from ..names import canonical_sampler
279
+
280
+ fields: dict = {}
281
+ folded = None
282
+ if request.sampler:
283
+ _, folded = canonical_sampler(request.sampler)
284
+ fields["sampler_name"] = pick_native(request.sampler, self.samplers(), sampler_id) or request.sampler
285
+ scheduler = request.scheduler or folded
286
+ if scheduler:
287
+ cid = canonical_scheduler(scheduler)
288
+ sigma = cid if cid in self.SIGMAS else ("betas" if cid == "beta" else "default")
289
+ fields["override_settings"] = {"schedulers_sigma": sigma}
290
+ return fields
291
+
292
+ def build_payload(self, request: Request) -> dict:
293
+ payload = super().build_payload(request)
294
+ if request.clip_skip:
295
+ payload["clip_skip"] = int(request.clip_skip)
296
+ # SD.Next ignores sd_model_checkpoint in override_settings when no model is loaded yet
297
+ # ("Aborted: op=process model not loaded"), so the model is switched through /options instead.
298
+ overrides = payload.get("override_settings") or {}
299
+ wanted = overrides.pop("sd_model_checkpoint", None)
300
+ if wanted:
301
+ current = self.http.get_json("/sdapi/v1/options", timeout=30).get("sd_model_checkpoint") or ""
302
+ if wanted not in (current, current.split(" [")[0]):
303
+ self.http.check(self.http.request("POST", "/sdapi/v1/options", json={"sd_model_checkpoint": wanted},
304
+ timeout=self.options.job_timeout), "model switch")
305
+ if not overrides:
306
+ payload.pop("override_settings", None)
307
+ payload.pop("override_settings_restore_afterwards", None)
308
+ return payload
sdmux/backends/base.py ADDED
@@ -0,0 +1,270 @@
1
+ """The interface every backend implements, and the parts they share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ..errors import NotReady, Rejected
10
+ from ..errors import SdmuxError
11
+ from ..names import canonical_scheduler, extract_loras, lora_stem, match_choice
12
+ from ..transport import Options, Transport
13
+ from ..types import IMG2IMG, INPAINT, TXT2IMG, Model, Progress, Request, Result, load_image
14
+
15
+ if TYPE_CHECKING:
16
+ from ..modelinfo import ModelInfo, Profile
17
+
18
+
19
+ class Backend:
20
+ """
21
+ A connection to one image generation server.
22
+
23
+ Listings: models(), loras(), samplers(), schedulers(), info().
24
+ Generation: txt2img(), img2img(), inpaint(), or generate(Request). They raise on failure.
25
+ Control: progress(), interrupt().
26
+ Identification: identify(model), profile(model).
27
+
28
+ Every backend also exposes its native request path (see each subclass) so nothing the
29
+ unified surface lacks blocks you.
30
+ """
31
+
32
+ kind = ""
33
+ label = ""
34
+ default_url = "http://127.0.0.1:7860"
35
+
36
+ #: Fixed values for settings neither the caller nor the model profile provides.
37
+ FALLBACK = {"width": 512, "height": 512, "steps": 20, "cfg": 7.0}
38
+
39
+ def __init__(self, url: str | None = None, options: Options | None = None, defaults: str = "profile",
40
+ **option_overrides) -> None:
41
+ """
42
+ defaults: "profile" (settings a call leaves out come from the model's profile: size, steps, cfg,
43
+ sampler, scheduler, clip skip) or "fixed" (sdmux's FALLBACK size, steps and cfg; sampler, scheduler
44
+ and clip skip are left to the server). Whatever is still missing after the profile gets FALLBACK.
45
+ """
46
+ if defaults not in ("profile", "fixed"):
47
+ raise ValueError("defaults must be 'profile' or 'fixed'.")
48
+ self.defaults = defaults
49
+ self._profiles: dict = {}
50
+ self.options = Options.build(options, **option_overrides)
51
+ self.http = Transport(url or self.default_url, self.options, self.label)
52
+ self.options = self.http.options
53
+ self._lock = threading.Lock()
54
+ self._cancel = threading.Event()
55
+
56
+ # Basics ---------------------------------------------------------------
57
+ @property
58
+ def url(self) -> str:
59
+ return self.http.base_url
60
+
61
+ def __repr__(self) -> str:
62
+ return f"<sdmux.{type(self).__name__} {self.kind} {self.url}>"
63
+
64
+ def close(self) -> None:
65
+ self.http.close()
66
+
67
+ def __enter__(self):
68
+ return self
69
+
70
+ def __exit__(self, *exc):
71
+ self.close()
72
+
73
+ # Listings -------------------------------------------------------------
74
+ def models(self) -> list[Model]:
75
+ raise NotImplementedError
76
+
77
+ def loras(self) -> list[Model]:
78
+ return []
79
+
80
+ def samplers(self) -> list[str]:
81
+ return []
82
+
83
+ def schedulers(self) -> list[str]:
84
+ return []
85
+
86
+ def info(self) -> dict:
87
+ return {"backend": self.kind, "url": self.url}
88
+
89
+ def ready(self) -> dict:
90
+ """
91
+ A slim readiness check: {"ready": bool, "problems": [str], "info": {...}}.
92
+
93
+ Raises Unreachable / AuthRefused when the server cannot be talked to at all.
94
+ """
95
+ problems = []
96
+ info = self.info()
97
+ if not self.models():
98
+ problems.append(f"{self.label} lists no models.")
99
+ return {"ready": not problems, "problems": problems, "info": info}
100
+
101
+ # Generation -----------------------------------------------------------
102
+ def txt2img(self, prompt: str, negative: str = "", **kwargs) -> Result:
103
+ return self.generate(Request(prompt=prompt, negative=negative, mode=TXT2IMG, **kwargs))
104
+
105
+ def img2img(self, image, prompt: str, negative: str = "", denoise: float = 0.75, **kwargs) -> Result:
106
+ return self.generate(Request(prompt=prompt, negative=negative, mode=IMG2IMG, image=image, denoise=denoise, **kwargs))
107
+
108
+ def inpaint(self, image, mask, prompt: str, negative: str = "", denoise: float = 0.75, **kwargs) -> Result:
109
+ """White mask pixels are repainted, black ones kept."""
110
+ return self.generate(
111
+ Request(prompt=prompt, negative=negative, mode=INPAINT, image=image, mask=mask, denoise=denoise, **kwargs)
112
+ )
113
+
114
+ def generate(self, request: Request) -> Result:
115
+ request = self._prepare(request)
116
+ filled = self._fill_defaults(request)
117
+ self._cancel.clear()
118
+ result = self._generate(request)
119
+ result.defaults = filled
120
+ return result
121
+
122
+ def current_model(self) -> str | None:
123
+ """The model a request without model= runs on. Most backends use the first listed one."""
124
+ return None
125
+
126
+ def _profile_for(self, model):
127
+ key = model.name if isinstance(model, Model) else (model or self.current_model() or "")
128
+ if key not in self._profiles:
129
+ try:
130
+ resolved = self.resolve_model(model or self.current_model())
131
+ self._profiles[key] = (resolved.name, self.identify(resolved).profile)
132
+ except (SdmuxError, OSError, ValueError):
133
+ self._profiles[key] = (None, None)
134
+ return self._profiles[key]
135
+
136
+ def _fill_defaults(self, request: Request) -> dict:
137
+ """Fill settings the caller left as None; returns {field: (value, source)}."""
138
+ filled: dict = {}
139
+
140
+ def put(name, value, source):
141
+ if getattr(request, name) is None and value is not None:
142
+ setattr(request, name, value)
143
+ filled[name] = (value, source)
144
+
145
+ if self.defaults == "profile":
146
+ name, profile = self._profile_for(request.model)
147
+ if profile is not None:
148
+ source = f"profile:{name}"
149
+ if request.mode == TXT2IMG:
150
+ put("width", profile.width, source)
151
+ put("height", profile.height, source)
152
+ put("steps", profile.steps, source)
153
+ put("cfg", profile.cfg, source)
154
+ if request.sampler is None and request.scheduler is None:
155
+ put("sampler", profile.sampler, source)
156
+ scheduler = profile.scheduler if profile.scheduler and profile.scheduler.lower() != "automatic" else None
157
+ put("scheduler", scheduler, source)
158
+ put("clip_skip", profile.clip_skip, source)
159
+ # Size, steps and cfg are always sent, so whatever is still missing gets sdmux's fixed values.
160
+ for name, value in self.FALLBACK.items():
161
+ put(name, value, "fallback")
162
+ return filled
163
+
164
+ def _generate(self, request: Request) -> Result:
165
+ raise NotImplementedError
166
+
167
+ def _prepare(self, request: Request) -> Request:
168
+ """Validate the request and load its images once, so backends receive PIL images."""
169
+ if request.mode not in (TXT2IMG, IMG2IMG, INPAINT):
170
+ raise ValueError(f"Unknown mode {request.mode!r}")
171
+ if request.mode in (IMG2IMG, INPAINT):
172
+ if request.image is None:
173
+ raise ValueError(f"{request.mode} needs an image.")
174
+ request.image = load_image(request.image)
175
+ if request.width is None:
176
+ request.width = request.image.width
177
+ if request.height is None:
178
+ request.height = request.image.height
179
+ if request.mode == INPAINT:
180
+ if request.mask is None:
181
+ raise ValueError("inpaint needs a mask (white = repaint).")
182
+ request.mask = load_image(request.mask)
183
+ if request.scheduler and canonical_scheduler(request.scheduler) == "automatic":
184
+ request.scheduler = None # the backend's own default
185
+ if request.patch is not None and self.kind != "comfyui":
186
+ from ..errors import Unsupported
187
+
188
+ raise Unsupported(f"patch= edits a ComfyUI graph; {self.label} has none.", backend=self.kind)
189
+ if request.batch_size < 1:
190
+ raise ValueError("batch_size must be at least 1.")
191
+ return request
192
+
193
+ # Control --------------------------------------------------------------
194
+ def progress(self) -> Progress:
195
+ return Progress()
196
+
197
+ def interrupt(self) -> None:
198
+ raise NotImplementedError
199
+
200
+ # Shared helpers -------------------------------------------------------
201
+ def resolve_model(self, wanted: "str | Model | None", models: list[Model] | None = None) -> Model:
202
+ """The listed model matching wanted (loosely), or the first one when wanted is None. A Model passes through."""
203
+ if isinstance(wanted, Model):
204
+ return wanted
205
+ models = self.models() if models is None else models
206
+ if not models:
207
+ raise NotReady(f"{self.label} lists no models.", backend=self.kind)
208
+ if not wanted:
209
+ return models[0]
210
+ for attr in ("name", "title", "path"):
211
+ names = [getattr(m, attr) for m in models if getattr(m, attr)]
212
+ found = match_choice(wanted, names)
213
+ if found:
214
+ return next(m for m in models if getattr(m, attr) == found)
215
+ raise Rejected(
216
+ f"{self.label} has no model matching {wanted!r}. It lists: "
217
+ + ", ".join(m.name for m in models[:20]) + (" ..." if len(models) > 20 else ""),
218
+ backend=self.kind,
219
+ )
220
+
221
+ def resolve_loras(self, prompt: str, available: list[Model] | None = None) -> tuple[str, list[tuple[Model, float]]]:
222
+ """Strip <lora:...> tags from prompt and match each against the backend's LoRAs. Unknown LoRAs raise Rejected."""
223
+ cleaned, wanted = extract_loras(prompt)
224
+ if not wanted:
225
+ return cleaned, []
226
+ available = self.loras() if available is None else available
227
+ found = []
228
+ for name, weight in wanted:
229
+ match = None
230
+ for attr in ("name", "title", "path"):
231
+ names = [getattr(m, attr) for m in available if getattr(m, attr)]
232
+ hit = match_choice(name, names)
233
+ if hit:
234
+ match = next(m for m in available if getattr(m, attr) == hit)
235
+ break
236
+ if match is None:
237
+ raise Rejected(f"{self.label} has no LoRA matching {name!r}.", backend=self.kind)
238
+ found.append((match, weight))
239
+ return cleaned, found
240
+
241
+ @staticmethod
242
+ def lora_name(model: Model) -> str:
243
+ return lora_stem(model.name)
244
+
245
+ # Identification -------------------------------------------------------
246
+ def local_path(self, model: Model) -> str | None:
247
+ """The model's file on this machine, when the backend reports a path that exists here."""
248
+ if model.path and os.path.isfile(model.path):
249
+ return model.path
250
+ return None
251
+
252
+ def identify(self, model: str | Model, online: bool = False) -> "ModelInfo":
253
+ """
254
+ identify() for a listed model. Reads the file when it is reachable from this machine,
255
+ otherwise works from the name and whatever the backend reports about it.
256
+ """
257
+ from ..modelinfo import identify, identify_name
258
+
259
+ if not isinstance(model, Model):
260
+ model = self.resolve_model(model)
261
+ path = self.local_path(model)
262
+ if path:
263
+ return identify(path, online=online)
264
+ return identify_name(model.name, architecture=model.architecture, kind=model.kind, sha256=model.sha256, online=online)
265
+
266
+ def profile(self, model: str | Model | None = None) -> "Profile":
267
+ """Prompt dialect and starting defaults for a model this backend lists."""
268
+ if model is None or not isinstance(model, Model):
269
+ model = self.resolve_model(model)
270
+ return self.identify(model).profile