Stable Diffusion: RunPod model catalog - Configurable model catalog (name -> model filename -> download sources, incl. companion files) in the RunPod settings block. - When the ComfyUI URL points at the runpod-lazy proxy, the Model dropdown lists catalog entries instead of querying the backend; selecting one pushes the catalog + active model to the proxy, which downloads exactly that set (re-provisioning a running pod on change). - homelab/: proxy v3 (catalog-driven provisioning, workflow filename matching on wake, /lazy/catalog endpoint).

630785ad4c5344a76994566c0b7fc880a116fa74

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

3 files changed, +264 -76Showing whitespace changes
homelab/runpod-lazy-proxy.py+117 -76
@@ -1,23 +1,25 @@
11#!/usr/bin/env python3
22"""ComfyUI lazy proxy for a volumeless RunPod on-demand pod (catalog edition).
33
44SillyTavern's fallback chain points at this proxy as a normal ComfyUI server;.
5-the pod downloads its models at boot (boot-models.py in the image), so no
5+Models come from a CATALOG pushed by the ST UI: entries of
6-network volume or region lock is needed.
6+ {"name": display, "value": model filename, "files": [{"dest": "unet/x.gguf", "url": "..."}]}
7-
7+The pod downloads exactly the selected entry's files at boot (boot-models.py
8-Control API (CORS-enabled, used by the ST warmup UI and the CLI script):
8+reads MODEL_MANIFEST). Changing the active model with a running pod recreates
9- GET /lazy/status -> {"state": "red"|"orange"|"green", ...}
9+it (fresh disk = old model gone, only the new one downloads).
10- POST /lazy/warmup -> start pod with MODELS=all (idempotent)
10+
11+Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI):
12+ GET /lazy/status -> {"state": red|orange|green, "model": ..., ...}
13+ POST /lazy/warmup -> start pod for the active catalog entry
1114 POST /lazy/shutdown -> terminate pod now
1215 POST /lazy/ping -> keepalive (extends idle timer; never starts a pod)
16+ POST /lazy/catalog -> {"models": [...], "active": "<value>"} store catalog;
17+ re-provision the pod if the active model changed
1318
1419Wake semantics: a ComfyUI /prompt POST arriving with no podready startspod onematches thatthe
15-downloads only the model group the workflow references (flux/qwen/all).
20+workflow against catalog filenames and provisions for that entry (falls back
1621to the active entry). The idle timer lives HEREhere (NAS side): IDLE_SECONDS after the last activity
1722signal (comfy trafficthe orlast keepaliveactivity ping)signal the pod is terminated, -frontend regardlessor no frontend.
18-of whether any frontend still exists.
19-
20-stdlib only; runs on python:3.12-alpine.
2123"""
2224import json
2325import os
@@ -30,7 +32,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
3032RUNPOD_KEY = os.environ['RUNPOD_KEY']
3133LISTEN_PORT = int(os.environ.get('LISTEN_PORT', '8189'))
3234IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
3335DATACENTERS = [d for d in os.environ.get('DATACENTERS', '').split(',') if d] # empty = any
3436CLOUD_TYPE = os.environ.get('CLOUD_TYPE', 'SECURE')
3537IMAGE = os.environ.get('IMAGE', 'ghcr.io/permissionbrick/comfyui-runpod-worker:latest')
3638GPU_TYPES = os.environ.get('GPU_TYPES', 'NVIDIA RTX 6000 Ada Generation,NVIDIA L40S,NVIDIA L40,NVIDIA RTX A6000,NVIDIA A40').split(',')
@@ -39,13 +41,11 @@ START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '1500'))
3941HF_TOKEN = os.environ.get('HF_TOKEN', '')
4042COMFY_ARGS = os.environ.get('COMFY_ARGS', '--listen 0.0.0.0 --port 8188 --use-pytorch-cross-attention')
4143CACHE_FILE = os.environ.get('CACHE_FILE', '/app/system_stats.cache')
42-
44+CATALOG_FILE = os.environ.get('CATALOG_FILE', '/app/catalog.json')
43-# Model-group detection: filename fragment (lowercased) -> group
44-GROUP_HINTS = {'flux-2-klein': 'flux', 'flux2-klein': 'flux', 'flux2-vae': 'flux', 'qwen-rapid': 'qwen'}
4545
4646state = {
47- 'pod_id': None, # current pod id (may still be booting)
47+ 'pod_id': None,
4848 'modelsmodel': None, # MODELScatalog `value` the pod was createdprovisioned withfor
4949 'phase': 'red', # red | orange | green
5050 'gpu': None,
5151 'since': time.time(),
@@ -58,6 +58,32 @@ def log(*args):
5858 print(time.strftime('%H:%M:%S'), *args, flush=True)
5959
6060
61+def load_catalog():
62+ try:
63+ with open(CATALOG_FILE) as f:
64+ return json.load(f)
65+ except Exception:
66+ return {'models': [], 'active': None}
67+
68+
69+def save_catalog(catalog):
70+ with open(CATALOG_FILE, 'w') as f:
71+ json.dump(catalog, f, indent=2)
72+
73+
74+def catalog_entry(value):
75+ if not value:
76+ return None
77+ for entry in load_catalog().get('models', []):
78+ if entry.get('value') == value:
79+ return entry
80+ return None
81+
82+
83+def active_value():
84+ return load_catalog().get('active')
85+
86+
6187def api(method, path, body=None):
6288 req = urllib.request.Request(
6389 f'https://rest.runpod.io/v1{path}',
@@ -73,13 +99,20 @@ def find_pod():
7399 try:
74100 for pod in api('GET', '/pods'):
75101 if pod.get('name') == POD_NAME and pod.get('desiredStatus') == 'RUNNING':
76- return pod['id'], (pod.get('env') or {}).get('MODELS'), pod.get('machine', {}).get('gpuTypeId')
102+ env = pod.get('env') or {}
103+ return pod['id'], env.get('MODEL_KEY'), pod.get('machine', {}).get('gpuTypeId')
77104 except Exception as err:
78105 log('find_pod failed:', err)
79106 return None, None, None
80107
81108
82109def create_pod(modelsvalue):
110+ entry = catalog_entry(value)
111+ env = {'HF_TOKEN': HF_TOKEN, 'MODEL_KEY': value or ''}
112+ if entry and entry.get('files'):
113+ env['MODEL_MANIFEST'] = json.dumps(entry['files'])
114+ else:
115+ env['MODELS'] = 'all' # legacy fallback when no catalog is configured
83116 body = {
84117 'name': POD_NAME,
85118 'imageName': IMAGE,
@@ -88,13 +121,13 @@ def create_pod(models):
88121 'cloudType': CLOUD_TYPE,
89122 'containerDiskInGb': 80,
90123 'ports': ['8188/http'],
91- 'env': {'MODELS': models, 'HF_TOKEN': HF_TOKEN},
124+ 'env': env,
92125 'dockerStartCmd': ['bash', '-c', f'python3 /boot-models.py && cd /comfyui && exec python main.py {COMFY_ARGS}'],
93126 }
94127 if DATACENTERS:
95128 body['dataCenterIds'] = DATACENTERS
96129 pod = api('POST', '/pods', body)
97130 log(f"pod created: {pod['id']} MODELSmodel={modelsvalue or 'legacy-all'} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr")
98131 return pod['id'], pod.get('machine', {}).get('gpuTypeId')
99132
100133
@@ -115,35 +148,33 @@ def upstream_ready(pod_id):
115148 return False
116149
117150
118151def groups_coverterminate(have, needpod_id):
119- if not have:
152+ try:
120- return False
153+ api('DELETE', f'/pods/{pod_id}')
121- have_set = set(have.split(','))
154+ log('pod terminated:', pod_id)
122- need_set = set(need.split(','))
155+ except Exception as err:
123- return 'all' in have_set or need_set <= have_set
156+ log('terminate failed:', err)
124157
125158
126159def ensure_pod(modelsvalue='all'None, wait=True):
127160 """Ensures a pod coveringprovisioned thefor requestedcatalog modelentry groups;`value` optionally(None waits= foractive readinessentry)."""
161+ value = value or active_value()
128162 with state['lock']:
129163 pod_id, have, gpu = (state['pod_id'], state['modelsmodel'], state['gpu'])
130164 if not pod_id:
131165 pod_id, have, gpu = find_pod()
132166 if pod_id and notvalue groups_cover(and have, models)!= value:
133167 log(f'pod has MODELSmodel={have}, need {modelsvalue} - recreating with all')
134- try:
168+ terminate(pod_id)
135- api('DELETE', f'/pods/{pod_id}')
136- except Exception as err:
137- log('delete failed:', err)
138169 pod_id, have, gpu = None, None, None
139- models = 'all'
140170 if not pod_id:
141171 state['phase'] = 'orange'
142172 state['since'] = time.time()
143173 last_err = None
144174 for attempt in range(3):
145175 try:
146176 pod_id, gpu = create_pod(modelsvalue)
177+ have = value
147178 break
148179 except urllib.error.HTTPError as err:
149180 last_err = err.read().decode()[:200]
@@ -152,8 +183,7 @@ def ensure_pod(models='all', wait=True):
152183 if not pod_id:
153184 state['phase'] = 'red'
154185 raise RuntimeError(f'could not create pod: {last_err}')
155- have = models
186+ state['pod_id'], state['model'], state['gpu'] = pod_id, have, gpu
156- state['pod_id'], state['models'], state['gpu'] = pod_id, have, gpu
157187 state['last'] = time.monotonic()
158188
159189 if state['phase'] != 'green':
@@ -162,12 +192,14 @@ def ensure_pod(models='all', wait=True):
162192 return pod_id
163193 deadline = time.monotonic() + START_TIMEOUT
164194 while time.monotonic() < deadline:
195+ # Booting/downloading counts as activity, else the reaper would kill a
196+ # warming pod mid-download.
197+ state['last'] = time.monotonic()
165198 if upstream_ready(pod_id):
166199 if state['phase'] != 'green':
167200 state['phase'] = 'green'
168201 state['since'] = time.time()
169202 log('pod ready:', pod_id)
170- state['last'] = time.monotonic()
171203 return pod_id
172204 if state['pod_id'] != pod_id:
173205 raise RuntimeError('pod replaced while waiting')
@@ -175,15 +207,17 @@ def ensure_pod(models='all', wait=True):
175207 raise RuntimeError('pod not ready within START_TIMEOUT')
176208
177209
178210def detect_groupsmatch_workflow_model(workflow_bytes):
211+ """Returns the catalog value whose filename appears in the workflow, else None."""
179212 try:
180213 text = workflow_bytes.decode('utf-8', 'replace').lower()
181214 except Exception:
182215 return 'all'None
183- found = {group for hint, group in GROUP_HINTS.items() if hint in text}
216+ for entry in load_catalog().get('models', []):
184- if len(found) == 1:
217+ value = entry.get('value')
185- return found.pop()
218+ if value and value in text:
186- return 'all'
219+ return value
220+ return None
187221
188222
189223def idle_reaper():
@@ -194,16 +228,12 @@ def idle_reaper():
194228 pod_id = state['pod_id'] or find_pod()[0]
195229 if pod_id:
196230 log(f'idle {int(idle)}s - terminating pod')
197- try:
231+ terminate(pod_id)
198- api('DELETE', f'/pods/{pod_id}')
199- except Exception as err:
200- log('terminate failed:', err)
201232 if pod_id or state['phase'] != 'red':
202233 state.update({'pod_id': None, 'modelsmodel': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
203234
204235
205236def status_body():
206- # Refresh green-ness cheaply on demand
207237 pod_id = state['pod_id']
208238 if pod_id and state['phase'] != 'green' and upstream_ready(pod_id):
209239 state['phase'] = 'green'
@@ -211,14 +241,15 @@ def status_body():
211241 if not pod_id:
212242 found, have, gpu = find_pod()
213243 if found:
214244 state.update({'pod_id': found, 'modelsmodel': have, 'gpu': gpu})
215245 state['phase'] = 'green' if upstream_ready(found) else 'orange'
216- else:
246+ elif state['phase'] != 'orange':
217247 state['phase'] = 'red'
218248 return json.dumps({
219249 'state': state['phase'],
220250 'pod_id': state['pod_id'],
221251 'modelsmodel': state['modelsmodel'],
252+ 'active': active_value(),
222253 'gpu': state['gpu'],
223254 'since': state['since'],
224255 'url': pod_url(state['pod_id']) if state['pod_id'] else None,
@@ -258,34 +289,44 @@ class Proxy(BaseHTTPRequestHandler):
258289 state['last'] = time.monotonic()
259290 return self._reply(200, b'{"ok": true}')
260291 if path == '/lazy/warmup':
261292 threading.Thread(target=self._safe_ensure, args=('all'None,), daemon=True).start()
262293 state['phase'] = state['phase'] if state['phase'] =!= 'green' else 'orange':
294+ state['phase'] = 'orange'
263295 return self._reply(200, status_body())
264296 if path == '/lazy/shutdown':
265297 pod_id = state['pod_id'] or find_pod()[0]
266298 if pod_id:
299+ terminate(pod_id)
300+ state.update({'pod_id': None, 'model': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
301+ return self._reply(200, status_body())
302+ if path == '/lazy/catalog':
267303 try:
268- api('DELETE', f'/pods/{pod_id}')
304+ payload = json.loads(body or b'{}')
269- except Exception as err:
305+ catalog = {'models': payload.get('models', []), 'active': payload.get('active')}
270- return self._reply(500, json.dumps({'error': str(err)}).encode())
306+ save_catalog(catalog)
271- state.update({'pod_id': None, 'models': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
307+ log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}")
308+ # Re-provision proactively when the active model changed under a live pod.
309+ if state['pod_id'] and catalog['active'] and state['model'] != catalog['active']:
310+ log('active model changed - re-provisioning pod')
311+ threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start()
272312 return self._reply(200, status_body())
313+ except Exception as err:
314+ return self._reply(400, json.dumps({'error': str(err)}).encode())
273315
274316 # ---- comfy proxying ----
275317 state['last'] = time.monotonic()
276318 pod_id = state['pod_id']
277319 ready = pod_id and upstream_ready(pod_id)
278320 if not ready and self.command == 'GET' and path == '/system_stats':
279- # Reachability probe while cold: answer from cache; do NOT wake.
280321 try:
281322 with open(CACHE_FILE, 'rb') as f:
282323 return self._reply(200, f.read())
283324 except FileNotFoundError:
284325 return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode())
285- if not ready:
326+ if not ready or (self.command == 'POST' and path == '/prompt'):
286327 modelsvalue = detect_groupsmatch_workflow_model(body) if (self.command == 'POST' and path == '/prompt' and body) else 'all'None
287328 try:
288329 pod_id = ensure_pod(modelsvalue)
289330 except Exception as err:
290331 log('ensure_pod failed:', err)
291332 return self._reply(502, json.dumps({'error': str(err)}).encode())
@@ -304,11 +345,11 @@ class Proxy(BaseHTTPRequestHandler):
304345 finally:
305346 state['last'] = time.monotonic()
306347
307348 def _safe_ensure(self, modelsvalue):
308349 try:
309350 ensure_pod(modelsvalue)
310351 except Exception as err:
311352 log('warmupprovision failed:', err)
312353
313354 do_GET = _handle
314355 do_POST = _handle
@@ -317,7 +358,7 @@ class Proxy(BaseHTTPRequestHandler):
317358def main():
318359 threading.Thread(target=idle_reaper, daemon=True).start()
319360 server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)
320361 log(f'runpod-lazy v2v3 (catalog) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')
321362 server.serve_forever()
322363
323364
public/scripts/extensions/stable-diffusion/index.js+138 -0
@@ -376,6 +376,9 @@ const defaultSettings = {
376376 // RunPod lazy-pod proxy base URL ('' = feature off)
377377 runpod_lazy_url: '',
378378
379+ // RunPod model catalog ({ name, value, downloads } entries)
380+ runpod_models: [],
381+
379382 // Dedicated LLM connection profile for image-prompt generation ('' = use active model)
380383 prompt_generation_profile: '',
381384
@@ -396,6 +399,7 @@ const PRESET_EXCLUDE_KEYS = [
396399 'ref_images_enabled',
397400 'ref_images',
398401 'runpod_lazy_url',
402+ 'runpod_models',
399403 // The image-prompt LLM profile is independent of the image backend, so it must
400404 // never be captured/swapped by image-generation presets or the fallback retry.
401405 'prompt_generation_profile',
@@ -713,6 +717,10 @@ async function loadSettings() {
713717 extension_settings.sd.ref_images = [];
714718 }
715719
720+ if (!Array.isArray(extension_settings.sd.runpod_models)) {
721+ extension_settings.sd.runpod_models = [];
722+ }
723+
716724 if (!Array.isArray(extension_settings.sd.custom_entries)) {
717725 extension_settings.sd.custom_entries = [];
718726 }
@@ -787,7 +795,9 @@ async function loadSettings() {
787795 $('#sd_runpod_lazy_url').val(extension_settings.sd.runpod_lazy_url ?? '');
788796 renderPresetChain();
789797 renderRefImages();
798+ renderRunpodModels();
790799 setupRunpodLoops();
800+ pushRunpodCatalog();
791801
792802 for (const style of extension_settings.sd.styles) {
793803 const option = document.createElement('option');
@@ -1594,6 +1604,122 @@ function onRunpodLazyUrlInput() {
15941604 setupRunpodLoops();
15951605}
15961606
1607+/**
1608+ * Whether a comfy URL points at the runpod-lazy proxy.
1609+ * @param {string} url ComfyUI URL to test.
1610+ * @returns {boolean} True when it equals the configured proxy URL.
1611+ */
1612+function isRunpodProxyUrl(url) {
1613+ const lazy = getRunpodLazyUrl();
1614+ return !!lazy && String(url ?? '').trim().replace(/\/$/, '') === lazy;
1615+}
1616+
1617+/**
1618+ * Returns the configured RunPod model catalog entries that have a filename.
1619+ * @returns {{name: string, value: string, downloads: string}[]} Catalog entries.
1620+ */
1621+function getRunpodCatalog() {
1622+ const models = Array.isArray(extension_settings.sd.runpod_models) ? extension_settings.sd.runpod_models : [];
1623+ return models.filter(m => m && String(m.value ?? '').trim());
1624+}
1625+
1626+/**
1627+ * Parses a catalog entry's download lines ("dest-subpath url" per line).
1628+ * @param {string} text Multiline downloads definition.
1629+ * @returns {{dest: string, url: string}[]} Parsed file list.
1630+ */
1631+function parseRunpodFiles(text) {
1632+ return String(text ?? '').split('\n')
1633+ .map(line => line.trim())
1634+ .filter(Boolean)
1635+ .map(line => {
1636+ const space = line.indexOf(' ');
1637+ return space > 0 ? { dest: line.slice(0, space).trim(), url: line.slice(space + 1).trim() } : null;
1638+ })
1639+ .filter(x => x && x.dest && x.url);
1640+}
1641+
1642+/**
1643+ * The model filename saved for whichever config uses the proxy: the live
1644+ * settings if they point at it, else the chain entry that does.
1645+ * @returns {string|null} Active catalog value.
1646+ */
1647+function getRunpodActiveModel() {
1648+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
1649+ return extension_settings.sd.model || null;
1650+ }
1651+ const chain = Array.isArray(extension_settings.sd.settings_preset_chain) ? extension_settings.sd.settings_preset_chain : [];
1652+ const entry = chain.find(e => isRunpodProxyUrl(e?.preset?.comfy_url));
1653+ return entry?.preset?.model ?? null;
1654+}
1655+
1656+/** Pushes the model catalog + active selection to the proxy (fire-and-forget). */
1657+async function pushRunpodCatalog() {
1658+ const url = getRunpodLazyUrl();
1659+ if (!url) {
1660+ return;
1661+ }
1662+ const models = getRunpodCatalog().map(m => ({
1663+ name: m.name || m.value,
1664+ value: m.value,
1665+ files: parseRunpodFiles(m.downloads),
1666+ }));
1667+ try {
1668+ await fetch(`${url}/lazy/catalog`, {
1669+ method: 'POST',
1670+ headers: { 'Content-Type': 'application/json' },
1671+ signal: AbortSignal.timeout(8000),
1672+ body: JSON.stringify({ models, active: getRunpodActiveModel() }),
1673+ });
1674+ } catch (error) {
1675+ console.warn('SD: runpod catalog push failed', error);
1676+ }
1677+}
1678+
1679+/**
1680+ * Rebuilds the RunPod model catalog editor in the settings UI.
1681+ */
1682+function renderRunpodModels() {
1683+ const container = $('#sd_runpod_models_list');
1684+ if (!container.length) {
1685+ return;
1686+ }
1687+ container.empty();
1688+ const models = Array.isArray(extension_settings.sd.runpod_models) ? extension_settings.sd.runpod_models : [];
1689+ if (models.length === 0) {
1690+ container.append($('<small></small>').text('No models configured.'));
1691+ return;
1692+ }
1693+ models.forEach((model, index) => {
1694+ const nameInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Display name' })
1695+ .val(model.name || '')
1696+ .on('change', function () { model.name = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
1697+ const valueInput = $('<input>').addClass('text_pole flex1').attr({ type: 'text', placeholder: 'Model filename (used by %model%)' })
1698+ .val(model.value || '')
1699+ .on('change', function () { model.value = String($(this).val() ?? '').trim(); saveSettingsDebounced(); pushRunpodCatalog(); });
1700+ const deleteButton = $('<div></div>').addClass('menu_button menu_button_icon fa-solid fa-trash-can')
1701+ .attr('title', 'Remove model')
1702+ .on('click', () => { models.splice(index, 1); saveSettingsDebounced(); renderRunpodModels(); pushRunpodCatalog(); });
1703+ const downloadsInput = $('<textarea></textarea>').addClass('text_pole textarea_compact')
1704+ .attr({ rows: 2, placeholder: 'One file per line: <models-subpath> <url>\ne.g. unet/model.gguf https://huggingface.co/...' })
1705+ .val(model.downloads || '')
1706+ .on('change', function () { model.downloads = String($(this).val() ?? ''); saveSettingsDebounced(); pushRunpodCatalog(); });
1707+ container.append(
1708+ $('<div></div>').addClass('flexFlowColumn marginTopBot5')
1709+ .append($('<div></div>').addClass('flex-container alignItemsCenter').append(nameInput).append(valueInput).append(deleteButton))
1710+ .append(downloadsInput));
1711+ });
1712+}
1713+
1714+function onRunpodModelsAddClick() {
1715+ if (!Array.isArray(extension_settings.sd.runpod_models)) {
1716+ extension_settings.sd.runpod_models = [];
1717+ }
1718+ extension_settings.sd.runpod_models.push({ name: '', value: '', downloads: '' });
1719+ saveSettingsDebounced();
1720+ renderRunpodModels();
1721+}
1722+
15971723// #endregion
15981724
15991725/**
@@ -2452,6 +2578,12 @@ async function onModelChange() {
24522578 extension_settings.sd.model = selectedModel.val();
24532579 saveSettingsDebounced();
24542580
2581+ // Selecting a catalog model while pointed at the RunPod proxy kicks off the
2582+ // download / re-provision for it right away.
2583+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
2584+ pushRunpodCatalog();
2585+ }
2586+
24552587 if (extension_settings.sd.model && extension_settings.sd.source === sources.electronhub) {
24562588 const cachedModel = selectedModel.data('model');
24572589 const models = cachedModel ? [cachedModel] : await loadElectronHubModels();
@@ -3502,6 +3634,11 @@ function loadNovelSchedulers() {
35023634}
35033635
35043636async function loadComfyModels() {
3637+ if (isRunpodProxyUrl(extension_settings.sd.comfy_url)) {
3638+ // The RunPod pod downloads models on demand: list the configured catalog
3639+ // instead of asking the (possibly cold) backend what it has on disk.
3640+ return getRunpodCatalog().map(m => ({ value: m.value, text: m.name || m.value }));
3641+ }
35053642 if (extension_settings.sd.comfy_type === comfyTypes.runpod_serverless) {
35063643 $('#sd_runpod_key').toggleClass('success', !!secret_state[SECRET_KEYS.COMFY_RUNPOD]);
35073644 return [
@@ -7045,6 +7182,7 @@ export async function init() {
70457182 $('#sd_runpod_lazy_url').on('input', onRunpodLazyUrlInput);
70467183 $('#sd_runpod_warmup').on('click', () => runpodControl('warmup'));
70477184 $('#sd_runpod_shutdown').on('click', () => runpodControl('shutdown'));
7185+ $('#sd_runpod_models_add').on('click', onRunpodModelsAddClick);
70487186 $('#sd_custom_entry_add').on('click', onAddCustomEntryClick);
70497187 $('#sd_custom_entries_list').on('click', '[data-action]', function () {
70507188 const id = $(this).attr('data-entry-id');
public/scripts/extensions/stable-diffusion/settings.html+9 -0
@@ -94,6 +94,15 @@
9494 </a>
9595 </div>
9696 <small data-i18n="sd_runpod_small">While a SillyTavern tab is open, a keepalive ping keeps the pod alive; it shuts down 15 minutes after the last activity either way. Red = off, orange = starting/downloading models, green = ready.</small>
97+ <h5 data-i18n="Model catalog">Model catalog</h5>
98+ <small data-i18n="sd_runpod_models_small">When the ComfyUI URL points at the proxy, the Model dropdown lists these entries instead of asking the backend. The pod downloads exactly the selected entry's files at boot; changing the selection re-provisions with the new model. Filename = what &quot;%model%&quot; resolves to; downloads = one &quot;subpath url&quot; per line, including companion files (text encoder, VAE, LoRA).</small>
99+ <div id="sd_runpod_models_list" class="flex-container flexFlowColumn marginTopBot5"></div>
100+ <div class="flex-container marginTopBot5">
101+ <div id="sd_runpod_models_add" class="menu_button menu_button_icon">
102+ <i class="fa-solid fa-plus"></i>
103+ <span data-i18n="Add model">Add model</span>
104+ </div>
105+ </div>
97106 <hr>
98107 <label for="sd_source" data-i18n="Source">Source</label>
99108 <select id="sd_source" class="text_pole">