Stable Diffusion: LoRA selection + per-workflow model/lora memory - LoRA dropdown for ComfyUI (new %lora% workflow placeholder; backend /api/sd/comfy/loras endpoint lists LoraLoader options; RunPod proxy URLs list catalog entries of kind 'lora' instead). - RunPod catalog entries are typed (model|lora); the active selection pushed to the proxy is now the model+lora set, and the pod provisions the union of their files (homelab: proxy v4). - Per-workflow memory: model + lora are remembered per comfy workflow (stored in comfy_workflow_prefs, part of preset snapshots -> each chain provider keeps its own map); switching workflows auto-restores them.

83a5205f51492f7446527d4a1a90bfc4c633b515

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

4 files changed, +193 -41Showing whitespace changes
homelab/runpod-lazy-proxy.py+45 -29
@@ -80,8 +80,15 @@ def catalog_entry(value):
8080 return None
8181
8282
8383def active_valueactive_values():
8484 returnactive = load_catalog().get('active')
85+ if isinstance(active, str):
86+ active = [active]
87+ return [a for a in (active or []) if a]
88+
89+
90+def values_key(values):
91+ return '+'.join(sorted(values)) if values else ''
8592
8693
8794def api(method, path, body=None):
@@ -106,11 +113,15 @@ def find_pod():
106113 return None, None, None
107114
108115
109116def create_pod(valuevalues):
117+ files = []
118+ for value in values or []:
110119 entry = catalog_entry(value)
111- env = {'HF_TOKEN': HF_TOKEN, 'MODEL_KEY': value or ''}
120+ if entry:
112- if entry and entry.get('files'):
121+ files.extend(entry.get('files') or [])
113- env['MODEL_MANIFEST'] = json.dumps(entry['files'])
122+ env = {'HF_TOKEN': HF_TOKEN, 'MODEL_KEY': values_key(values)}
123+ if files:
124+ env['MODEL_MANIFEST'] = json.dumps(files)
114125 else:
115126 env['MODELS'] = 'all' # legacy fallback when no catalog is configured
116127 body = {
@@ -127,7 +138,7 @@ def create_pod(value):
127138 if DATACENTERS:
128139 body['dataCenterIds'] = DATACENTERS
129140 pod = api('POST', '/pods', body)
130141 log(f"pod created: {pod['id']} modelmodels={valuevalues_key(values) or 'legacy-all'} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr")
131142 return pod['id'], pod.get('machine', {}).get('gpuTypeId')
132143
133144
@@ -156,17 +167,18 @@ def terminate(pod_id):
156167 log('terminate failed:', err)
157168
158169
159170def ensure_pod(valuevalues=None, wait=True):
160171 """Ensures a pod provisioned for catalog entry `value`values (None = active entryselection)."""
161172 valuevalues = valuevalues or active_valueactive_values()
173+ key = values_key(values)
162174 with state['lock']:
163175 pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu'])
164176 if not pod_id:
165177 pod_id, have, gpu = find_pod()
166178 # Only recreate when the pod's model set is KNOWN and different. An unknown
167179 # modelunknown set (e.g. env not reported yet) must not churn pods.
168180 if pod_id and valuekey and have and have != valuekey:
169181 log(f'pod has modelmodels={have}, need {valuekey} - recreating')
170182 terminate(pod_id)
171183 pod_id, have, gpu = None, None, None
172184 if not pod_id:
@@ -175,8 +187,8 @@ def ensure_pod(value=None, wait=True):
175187 last_err = None
176188 for attempt in range(3):
177189 try:
178190 pod_id, gpu = create_pod(valuevalues)
179191 have = valuekey
180192 break
181193 except urllib.error.HTTPError as err:
182194 last_err = err.read().decode()[:200]
@@ -209,17 +221,18 @@ def ensure_pod(value=None, wait=True):
209221 raise RuntimeError('pod not ready within START_TIMEOUT')
210222
211223
212224def match_workflow_modelmatch_workflow_models(workflow_bytes):
213225 """Returns theall catalog valuevalues whose filenamefilenames appearsappear in the workflow, else None."""
214226 try:
215227 text = workflow_bytes.decode('utf-8', 'replace')
216228 except Exception:
217229 return None[]
230+ found = []
218231 for entry in load_catalog().get('models', []):
219232 value = entry.get('value')
220233 if value and value in text:
221- return value
234+ found.append(value)
222235 return Nonefound
223236
224237
225238def idle_reaper():
@@ -251,7 +264,7 @@ def status_body():
251264 'state': state['phase'],
252265 'pod_id': state['pod_id'],
253266 'model': state['model'],
254267 'active': active_valueactive_values(),
255268 'gpu': state['gpu'],
256269 'since': state['since'],
257270 'url': pod_url(state['pod_id']) if state['pod_id'] else None,
@@ -304,13 +317,16 @@ class Proxy(BaseHTTPRequestHandler):
304317 if path == '/lazy/catalog':
305318 try:
306319 payload = json.loads(body or b'{}')
307- catalog = {'models': payload.get('models', []), 'active': payload.get('active')}
320+ active = payload.get('active')
321+ if isinstance(active, str):
322+ active = [active]
323+ catalog = {'models': payload.get('models', []), 'active': [a for a in (active or []) if a]}
308324 save_catalog(catalog)
309325 log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}")
310326 # Re-provision proactively when the active model changed under a live pod
311327 # (known-model pods only - never churn a pod of unknown provenance).
312328 if state['pod_id'] and catalog['active'] and state['model'] and state['model'] != values_key(catalog['active']):
313329 log('active model set changed - re-provisioning pod')
314330 threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start()
315331 return self._reply(200, status_body())
316332 except Exception as err:
@@ -327,9 +343,9 @@ class Proxy(BaseHTTPRequestHandler):
327343 except FileNotFoundError:
328344 return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode())
329345 if not ready or (self.command == 'POST' and path == '/prompt'):
330346 valuevalues = match_workflow_modelmatch_workflow_models(body) if (self.command == 'POST' and path == '/prompt' and body) else None
331347 try:
332348 pod_id = ensure_pod(valuevalues or None)
333349 except Exception as err:
334350 log('ensure_pod failed:', err)
335351 return self._reply(502, json.dumps({'error': str(err)}).encode())
@@ -369,12 +385,12 @@ def main():
369385
370386 def _adopt():
371387 try:
372- ensure_pod(have)
388+ ensure_pod(have.split('+') if have else None)
373389 except Exception as err:
374390 log('adoption wait failed:', err)
375391 threading.Thread(target=_adopt, daemon=True).start()
376392 server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)
377393 log(f'runpod-lazy v3v4 (catalog+lora) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')
378394 server.serve_forever()
379395
380396
public/scripts/extensions/stable-diffusion/index.js+124 -12
@@ -376,9 +376,16 @@ const defaultSettings = {
376376 // RunPod lazy-pod proxy base URL ('' = feature off)
377377 runpod_lazy_url: '',
378378
379379 // RunPod model catalog ({ name, value, kind: 'model'|'lora', downloads } entries)
380380 runpod_models: [],
381381
382+ // Selected LoRA for comfy workflows using the %lora% placeholder
383+ lora: '',
384+
385+ // Per-workflow remembered selections ({ [workflow]: { model, lora } });
386+ // part of preset snapshots, so each chain provider keeps its own map.
387+ comfy_workflow_prefs: {},
388+
382389 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
383390 prompt_generation_profile: '',
384391
@@ -721,6 +728,14 @@ async function loadSettings() {
721728 extension_settings.sd.runpod_models = [];
722729 }
723730
731+ if (extension_settings.sd.lora === undefined) {
732+ extension_settings.sd.lora = '';
733+ }
734+
735+ if (typeof extension_settings.sd.comfy_workflow_prefs !== 'object' || !extension_settings.sd.comfy_workflow_prefs) {
736+ extension_settings.sd.comfy_workflow_prefs = {};
737+ }
738+
724739 if (!Array.isArray(extension_settings.sd.custom_entries)) {
725740 extension_settings.sd.custom_entries = [];
726741 }
@@ -853,10 +868,48 @@ async function loadSettingOptions() {
853868 loadModels(),
854869 loadSchedulers(),
855870 loadVaes(),
871+ loadLoras(),
856872 loadComfyWorkflows(),
857873 ]);
858874}
859875
876+async function loadLoras() {
877+ $('#sd_lora').empty();
878+ let loras = ['N/A'];
879+ if (extension_settings.sd.source === sources.comfy && extension_settings.sd.comfy_type === comfyTypes.standard) {
880+ loras = await loadComfyLoras();
881+ }
882+ for (const lora of loras) {
883+ const option = document.createElement('option');
884+ option.innerText = lora?.text ?? lora;
885+ option.value = lora?.value ?? lora;
886+ option.selected = option.value === extension_settings.sd.lora;
887+ $('#sd_lora').append(option);
888+ }
889+}
890+
891+async function loadComfyLoras() {
892+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
893+ return getRunpodCatalog().filter(m => m.kind === 'lora').map(m => ({ value: m.value, text: m.name || m.value }));
894+ }
895+ if (!extension_settings.sd.comfy_url) {
896+ return [];
897+ }
898+ try {
899+ const result = await fetch('/api/sd/comfy/loras', {
900+ method: 'POST',
901+ headers: getRequestHeaders(),
902+ body: JSON.stringify({ url: extension_settings.sd.comfy_url }),
903+ });
904+ if (!result.ok) {
905+ throw new Error('ComfyUI returned an error.');
906+ }
907+ return await result.json();
908+ } catch (error) {
909+ return [];
910+ }
911+}
912+
860913function addPromptTemplates() {
861914 $('#sd_prompt_templates').empty();
862915
@@ -1623,6 +1676,11 @@ function getRunpodCatalog() {
16231676 return models.filter(m => m && String(m.value ?? '').trim());
16241677}
16251678
1679+/** Catalog entries that go in the Model dropdown (kind !== 'lora'). */
1680+function getRunpodModelEntries() {
1681+ return getRunpodCatalog().filter(m => m.kind !== 'lora');
1682+}
1683+
16261684/**
16271685 * Parses a catalog entry's download lines ("dest-subpath url" per line).
16281686 * @param {string} text Multiline downloads definition.
@@ -1640,17 +1698,23 @@ function parseRunpodFiles(text) {
16401698}
16411699
16421700/**
16431701 * The model+lora filenamefilenames saved for whichever config uses the proxy: the live
16441702 * settings if they point at it, else the chain entry that does.
16451703 * @returns {string|null[]} Active catalog valuevalues (model and/or lora).
16461704 */
16471705function getRunpodActiveModelgetRunpodActiveModels() {
1706+ let config = null;
16481707 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
16491708 returnconfig = extension_settings.sd.model || null;
16501709 } else {
16511710 const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
16521711 const entry config = chain.find(e => isRunpodProxyUrl(e?.preset?.comfy_url))?.preset ?? null;
1653- return entry?.preset?.model ?? null;
1712+ }
1713+ if (!config) {
1714+ return [];
1715+ }
1716+ const known = new Set(getRunpodCatalog().map(m => m.value));
1717+ return [config.model, config.lora].filter(v => v && known.has(v));
16541718}
16551719
16561720/** Pushes the model catalog + active selection to the proxy (fire-and-forget). */
@@ -1662,6 +1726,7 @@ async function pushRunpodCatalog() {
16621726 const models = getRunpodCatalog().map(m => ({
16631727 name: m.name || m.value,
16641728 value: m.value,
1729+ kind: m.kind === 'lora' ? 'lora' : 'model',
16651730 files: parseRunpodFiles(m.downloads),
16661731 }));
16671732 try {
@@ -1669,7 +1734,7 @@ async function pushRunpodCatalog() {
16691734 method: 'POST',
16701735 headers: { 'Content-Type': 'application/json' },
16711736 signal: AbortSignal.timeout(8000),
16721737 body: JSON.stringify({ models, active: getRunpodActiveModelgetRunpodActiveModels() }),
16731738 });
16741739 } catch (error) {
16751740 console.warn('SD: runpod catalog push failed', error);
@@ -1694,9 +1759,14 @@ function renderRunpodModels() {
16941759 const nameInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Display name' })
16951760 .val(model.name || '')
16961761 .on('change', function () { model.name = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
16971762 const valueInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Model filenameFilename (used by %model% / %lora%)' })
16981763 .val(model.value || '')
16991764 .on('change', function () { model.value = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
1765+ const kindSelect = $('<select></select>').addClass('text_pole')
1766+ .append($('<option></option>').val('model').text('model'))
1767+ .append($('<option></option>').val('lora').text('lora'))
1768+ .val(model.kind === 'lora' ? 'lora' : 'model')
1769+ .on('change', function () { model.kind = String($(this).val()); saveSettingsDebounced(); pushRunpodCatalog(); });
17001770 const deleteButton = $('<div></div>').addClass('menu_button menu_button_icon fa-solid fa-trash-can')
17011771 .attr('title', 'Remove model')
17021772 .on('click', () => { models.splice(index, 1); saveSettingsDebounced(); renderRunpodModels(); pushRunpodCatalog(); });
@@ -1706,7 +1776,7 @@ function renderRunpodModels() {
17061776 .on('change', function () { model.downloads = String($(this).val() ?? ''); saveSettingsDebounced(); pushRunpodCatalog(); });
17071777 container.append(
17081778 $('<div></div>').addClass('flexFlowColumn marginTopBot5')
17091779 .append($('<div></div>').addClass('flex-container alignItemsCenter').append(nameInput).append(valueInput).append(kindSelect).append(deleteButton))
17101780 .append(downloadsInput));
17111781 });
17121782}
@@ -2406,8 +2476,47 @@ function onHFModelInput() {
24062476 saveSettingsDebounced();
24072477}
24082478
2479+/**
2480+ * Stores the current model/lora choice for the active comfy workflow, so
2481+ * switching back to this workflow restores it (per provider via presets).
2482+ * @param {'model'|'lora'} key Which selection to remember.
2483+ * @param {string} value Selected value.
2484+ */
2485+function rememberWorkflowPref(key, value) {
2486+ if (extension_settings.sd.source !== sources.comfy || !extension_settings.sd.comfy_workflow) {
2487+ return;
2488+ }
2489+ const prefs = extension_settings.sd.comfy_workflow_prefs;
2490+ prefs[extension_settings.sd.comfy_workflow] = { ...(prefs[extension_settings.sd.comfy_workflow] ?? {}), [key]: value };
2491+}
2492+
2493+async function onLoraChange() {
2494+ extension_settings.sd.lora = $('#sd_lora').find(':selected').val();
2495+ rememberWorkflowPref('lora', extension_settings.sd.lora);
2496+ saveSettingsDebounced();
2497+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2498+ pushRunpodCatalog();
2499+ }
2500+}
2501+
24092502function onComfyWorkflowChange() {
24102503 extension_settings.sd.comfy_workflow = $('#sd_comfy_workflow').find(':selected').val();
2504+ // Restore the model/lora remembered for this workflow (auto-configuration
2505+ // when switching between e.g. the flux and qwen workflow families).
2506+ const pref = extension_settings.sd.comfy_workflow_prefs?.[extension_settings.sd.comfy_workflow];
2507+ if (pref) {
2508+ if (pref.model) {
2509+ extension_settings.sd.model = pref.model;
2510+ $('#sd_model').val(pref.model);
2511+ }
2512+ if (pref.lora !== undefined) {
2513+ extension_settings.sd.lora = pref.lora;
2514+ $('#sd_lora').val(pref.lora);
2515+ }
2516+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2517+ pushRunpodCatalog();
2518+ }
2519+ }
24112520 saveSettingsDebounced();
24122521}
24132522
@@ -2576,6 +2685,7 @@ async function validateComfyRunPodUrl() {
25762685async function onModelChange() {
25772686 const selectedModel = $('#sd_model').find(':selected');
25782687 extension_settings.sd.model = selectedModel.val();
2688+ rememberWorkflowPref('model', extension_settings.sd.model);
25792689 saveSettingsDebounced();
25802690
25812691 // Selecting a catalog model while pointed at the RunPod proxy kicks off the
@@ -3637,7 +3747,7 @@ async function loadComfyModels() {
36373747 if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
36383748 // The RunPod pod downloads models on demand: list the configured catalog
36393749 // instead of asking the (possibly cold) backend what it has on disk.
36403750 return getRunpodCataloggetRunpodModelEntries().map(m => ({ value: m.value, text: m.name || m.value }));
36413751 }
36423752 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
36433753 $('#sd_runpod_key').toggleClass('success', !!secret_state[SECRET_KEYS.COMFY_RUNPOD]);
@@ -5587,6 +5697,7 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
55875697 const placeholders = [
55885698 'model',
55895699 'vae',
5700+ 'lora',
55905701 'sampler',
55915702 'scheduler',
55925703 'steps',
@@ -7121,6 +7232,7 @@ export async function init() {
71217232 $('#sd_scale').on('input', onScaleInput);
71227233 $('#sd_steps').on('input', onStepsInput);
71237234 $('#sd_model').on('change', onModelChange);
7235+ $('#sd_lora').on('change', onLoraChange);
71247236 $('#sd_vae').on('change', onVaeChange);
71257237 $('#sd_sampler').on('change', onSamplerChange);
71267238 $('#sd_resolution').on('change', onResolutionChange);
public/scripts/extensions/stable-diffusion/settings.html+5 -0
@@ -498,6 +498,11 @@
498498 <label for="sd_vae">VAE</label>
499499 <select id="sd_vae" class="text_pole"></select>
500500 </div>
501+
502+ <div class="flex1" data-sd-source="comfy">
503+ <label for="sd_lora" data-i18n="LoRA">LoRA</label>
504+ <select id="sd_lora" class="text_pole"></select>
505+ </div>
501506 </div>
502507
503508 <div class="flex-container">
src/endpoints/stable-diffusion.js+19 -0
@@ -482,6 +482,25 @@ comfy.post('/vaes', async (request, response) => {
482482 }
483483});
484484
485+comfy.post('/loras', async (request, response) => {
486+ try {
487+ const url = new URL(urlJoin(request.body.url, '/object_info'));
488+
489+ const result = await fetch(url);
490+ if (!result.ok) {
491+ throw new Error('ComfyUI returned an error.');
492+ }
493+
494+ /** @type {any} */
495+ const data = await result.json();
496+ const loras = data.LoraLoader?.input?.required?.lora_name?.[0] ?? [];
497+ return response.send(loras);
498+ } catch (error) {
499+ console.error(error);
500+ return response.sendStatus(500);
501+ }
502+});
503+
485504comfy.post('/workflows', async (request, response) => {
486505 try {
487506 const data = getComfyWorkflows(request.user.directories);