Ver código fonte

feat: split the SD.cpp model node into one node per job

The old sdcpp-model did seven things behind an operation dropdown - list, load,
unload, loadUpscaler, unloadUpscaler, refresh, health - so its config was a pile
of fields most of which did not apply to whatever you had chosen. That is the
same shape ComfyUI avoids with separate loader nodes, and it is why picking a
model was awkward.

Now:

- SD.cpp Model (v2) picks a model from what the server actually has. Choose a
  kind - checkpoint, vae, esrgan, taesd, lora and the rest - and the list comes
  from the server. It supplies its choice through the same _config marker the
  Configurator uses, so it connects to any node that needs a model, and it works
  out which setting to fill from the kind: a checkpoint fills modelName, a VAE
  fills vae, a ControlNet fills controlnet. "Supplies" overrides that when a
  node names it differently.
- SD.cpp Load Model makes sure a model is loaded. It reads /health first and
  does nothing when the right model is already resident, because a load takes
  minutes, unloads whatever was there, and on a shared server disrupts other
  work. Force Reload overrides that. "When A Different Model Is Loaded" can be
  set to fail instead of load, for a workflow that depends on a particular model
  being in place and should not quietly spend minutes swapping it.
- SD.cpp Load Upscaler does the same for the upscaler slot, which is independent
  of the main model - both can be loaded at once.
- SD.cpp Unload frees the model slot, the upscaler slot, or both. Nothing to
  unload is a success: a cleanup step should not fail because an earlier branch
  already freed it.

Health moved out to the SD.cpp Health node, which existed already, so its
fixture on the old node is gone rather than rewritten.

The model list reaches the editor through the node-options endpoint: the picker
returns models when run with listOnly, and nothing else - a workflow that listed
358 models on every execution would be paying for nothing.

Verified against the live server: 24 esrgan models and 40 checkpoints listed
through the stored credential; a Configurator feeding serverUrl and credentialId
to both the picker and the loader while the picker feeds modelName to the same
loader - three sources merging with no conflict - and the loader correctly
reporting alreadyLoaded rather than reloading AnythingXL_v50; and the assert
mode failing when the wanted model is not the one loaded. Full suite 55/55.

The dropdown itself is still to come; the endpoint behind it works.
fszontagh 1 mês atrás
pai
commit
f0394f86a4

+ 255 - 0
nodes/sdcpp/sdcpp-model-load.js

@@ -0,0 +1,255 @@
+/**
+ * @node sdcpp-model-load
+ * @name SD.cpp Load Model
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Make sure a model is loaded, without reloading one that already is
+ * @icon box
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        modelName: {
+            type: 'string', title: 'Model',
+            description: 'File name of the model, relative to its type directory. Usually supplied by an SD.cpp Model node rather than typed here'
+        },
+        modelType: {
+            type: 'string', title: 'Model Type',
+            enum: ['', 'checkpoint', 'diffusion'],
+            default: '',
+            description: 'checkpoint bundles U-Net, CLIP and VAE and suits SD1, SD2 and SDXL. diffusion holds only the U-Net or DiT and needs its components named separately, which is how Flux, SD3, Qwen, Wan and Z-Image load'
+        },
+        vae: { type: 'string', title: 'VAE', description: 'Component file name' },
+        clipL: { type: 'string', title: 'CLIP-L', description: 'Component file name' },
+        clipG: { type: 'string', title: 'CLIP-G', description: 'Component file name' },
+        t5xxl: { type: 'string', title: 'T5-XXL', description: 'Component file name' },
+        llm: { type: 'string', title: 'LLM', description: 'Component file name, used by Z-Image, Qwen, Anima and Flux2' },
+        taesd: { type: 'string', title: 'TAESD', description: 'Tiny autoencoder for progress previews' },
+        controlnet: { type: 'string', title: 'ControlNet', description: 'Component file name' },
+        options: {
+            type: 'object', title: 'Load Options',
+            description: 'Extra load options passed through, such as flash_attn, enable_mmap, weight_type, stream_layers or max_vram'
+        },
+        whenDifferent: {
+            type: 'string', title: 'When A Different Model Is Loaded',
+            enum: ['load', 'fail'],
+            default: 'load',
+            description: 'load swaps it. fail stops the run instead - for a workflow that depends on a particular model already being in place and should not quietly spend minutes swapping it'
+        },
+        force: {
+            type: 'boolean', title: 'Force Reload',
+            default: false,
+            description: 'Load again even when the right model is already loaded. Costs the full load time; useful after changing components or options, which this node cannot see from outside',
+            showWhen: { field: 'whenDifferent', value: 'load' }
+        },
+        timeout: {
+            type: 'number', title: 'Timeout (ms)',
+            description: 'Loading reads gigabytes from disk and can take minutes',
+            default: 300000
+        }
+    },
+    required: []
+};
+
+const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        modelName: { type: 'string', description: 'The model that is loaded now' },
+        modelType: { type: 'string' },
+        architecture: { type: 'string', description: 'Architecture the server detected, which decides generation defaults' },
+        loaded: { type: 'boolean', description: 'True when this node performed a load' },
+        alreadyLoaded: { type: 'boolean', description: 'True when the right model was already in place and nothing was done' },
+        previousModel: { type: 'string', description: 'What was loaded before, when this node swapped it' },
+        loadedComponents: { type: 'object' },
+        elapsedMs: { type: 'number' }
+    }
+};
+
+function normalizeServer(url) {
+    const value = String(url || '').trim();
+    if (!value) {
+        throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
+    }
+    return value.replace(/\/+$/, '');
+}
+
+function readCredential(credentialId) {
+    const auth = smartbotic.credentials.get(credentialId);
+    if (!auth || auth.success !== true) {
+        throw new Error('SD.cpp: could not read the credential: ' +
+            ((auth && auth.error) || 'unknown error'));
+    }
+
+    const value = auth.headerValue || '';
+    if (value.indexOf('Basic ') !== 0) {
+        throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' +
+            'username and password');
+    }
+
+    const decoded = smartbotic.utils.base64Decode(value.substring(6));
+    const separator = decoded.indexOf(':');
+    if (separator < 1) {
+        throw new Error('SD.cpp: the credential is malformed, expected a username and a password');
+    }
+
+    return {
+        username: decoded.substring(0, separator),
+        password: decoded.substring(separator + 1)
+    };
+}
+
+function call(options) {
+    const response = smartbotic.http.request(options);
+
+    let body = response.data;
+    if (typeof body === 'string' && body.length > 0) {
+        try {
+            body = JSON.parse(body);
+        } catch (e) {
+            const snippet = body.substring(0, 200).replace(/\s+/g, ' ');
+            throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status +
+                ' with a body that is not JSON: ' + snippet);
+        }
+    }
+
+    if (response.status < 200 || response.status >= 300) {
+        const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status);
+        throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail);
+    }
+
+    return body || {};
+}
+
+function login(server, credential, timeout) {
+    const session = call({
+        method: 'POST',
+        url: server + '/auth/login',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+            username: credential.username,
+            password: credential.password
+        }),
+        timeout: timeout,
+        what: 'signing in'
+    });
+
+    if (!session.token) {
+        throw new Error('SD.cpp: the server accepted the login but returned no token');
+    }
+    return session.token;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout > 0 ? config.timeout : 300000;
+    const modelName = String(config.modelName || '').trim();
+
+    if (!modelName) {
+        throw new Error('SD.cpp: a model name is required. Connect an SD.cpp Model node, ' +
+            'or type the file name');
+    }
+
+    // Ask what is loaded before loading anything. A load takes minutes and
+    // unloads whatever was there, so doing it when the right model is already
+    // resident is pure cost - and on a shared server it disrupts other work.
+    const startedAt = Date.now();
+    const health = readHealth(server, Math.min(timeout, 15000));
+    const current = health.model_name || '';
+    const sameModel = current === modelName;
+
+    if (sameModel && config.force !== true) {
+        smartbotic.log.info('SD.cpp: ' + modelName + ' is already loaded, nothing to do');
+        return {
+            modelName: current,
+            modelType: health.model_type || '',
+            architecture: health.model_architecture || '',
+            loaded: false,
+            alreadyLoaded: true,
+            previousModel: '',
+            loadedComponents: health.loaded_components || {},
+            elapsedMs: Date.now() - startedAt
+        };
+    }
+
+    if (!sameModel && (config.whenDifferent || 'load') === 'fail') {
+        throw new Error('SD.cpp: this workflow expects "' + modelName + '" to be loaded, but ' +
+            (current ? 'the server has "' + current + '"' : 'no model is loaded') +
+            '. Set When A Different Model Is Loaded to "load" to swap it automatically');
+    }
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, Math.min(timeout, 30000));
+
+    const body = { model_name: modelName };
+    putIfSet(body, 'model_type', config.modelType);
+    putIfSet(body, 'vae', config.vae);
+    putIfSet(body, 'clip_l', config.clipL);
+    putIfSet(body, 'clip_g', config.clipG);
+    putIfSet(body, 't5xxl', config.t5xxl);
+    putIfSet(body, 'llm', config.llm);
+    putIfSet(body, 'taesd', config.taesd);
+    putIfSet(body, 'controlnet', config.controlnet);
+    if (config.options && typeof config.options === 'object') {
+        body.options = config.options;
+    }
+
+    smartbotic.log.info('SD.cpp: loading ' + modelName +
+        (current ? ' (replacing ' + current + ')' : ''));
+
+    // Loading unloads whatever was in the slot first, and the server holds a
+    // mutex for the duration, so this blocks until the weights are resident.
+    const loaded = call({
+        method: 'POST',
+        url: server + '/models/load',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        body: JSON.stringify(body),
+        timeout: timeout,
+        what: 'loading model ' + modelName
+    });
+
+    const after = readHealth(server, Math.min(timeout, 15000));
+
+    return {
+        modelName: loaded.model_name || modelName,
+        modelType: loaded.model_type || config.modelType || '',
+        architecture: after.model_architecture || '',
+        loaded: true,
+        alreadyLoaded: false,
+        previousModel: sameModel ? '' : current,
+        loadedComponents: loaded.loaded_components || after.loaded_components || {},
+        elapsedMs: Date.now() - startedAt
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 131 - 327
nodes/sdcpp/sdcpp-model.js

@@ -2,8 +2,8 @@
  * @node sdcpp-model
  * @name SD.cpp Model
  * @category sdcpp
- * @version 1.0.0
- * @description List, load or unload models on an sdcpp-restapi server, and read its health
+ * @version 2.0.0
+ * @description Pick a model from what the server has, and hand it to whichever node needs one
  * @icon layers
  */
 
@@ -11,101 +11,94 @@ const configSchema = {
     type: 'object',
     properties: {
         serverUrl: {
-            type: 'string',
-            title: 'Server URL',
-            description: 'Base address of the sdcpp-restapi server',
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server. The list of models is read from here',
             default: 'http://localhost:8077'
         },
         credentialId: {
-            type: 'string',
-            title: 'Credential',
-            description: 'A basic credential holding the sdcpp-restapi username and password. Not needed for the health operation, which the server leaves open',
-            dynamicOptions: {
-                source: 'credentials',
-                filter: { type: ['basic'] }
-            }
-        },
-        operation: {
-            type: 'string',
-            title: 'Operation',
-            enum: ['health', 'list', 'load', 'unload', 'loadUpscaler', 'unloadUpscaler', 'refresh'],
-            default: 'health',
-            description: 'health reports what is loaded now, list shows what is available on disk, load swaps the main model slot, loadUpscaler fills the separate upscaler slot, refresh rescans the model directories'
-        },
-        modelName: {
-            type: 'string',
-            title: 'Model Name',
-            description: 'File name of the model to load, relative to its type directory. Required for load and loadUpscaler'
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password. Listing models needs one',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
         },
         modelType: {
-            type: 'string',
-            title: 'Model Type',
-            enum: ['', 'checkpoint', 'diffusion'],
-            default: '',
-            description: 'checkpoint bundles the U-Net, CLIP and VAE together and suits SD1, SD2 and SDXL. diffusion holds only the U-Net or DiT weights and needs its components named separately, which is how Flux, SD3, Qwen, Wan and Z-Image load'
-        },
-        vae: { type: 'string', title: 'VAE', description: 'Component file name, for a diffusion model' },
-        clipL: { type: 'string', title: 'CLIP-L', description: 'Component file name' },
-        clipG: { type: 'string', title: 'CLIP-G', description: 'Component file name' },
-        t5xxl: { type: 'string', title: 'T5-XXL', description: 'Component file name' },
-        llm: { type: 'string', title: 'LLM', description: 'Component file name, used by Z-Image, Qwen, Anima and Flux2' },
-        taesd: { type: 'string', title: 'TAESD', description: 'Tiny autoencoder used to render progress previews, not final output' },
-        controlnet: { type: 'string', title: 'ControlNet', description: 'Component file name' },
-        options: {
-            type: 'object',
-            title: 'Load Options',
-            description: 'Extra load options passed straight through, such as flash_attn, enable_mmap, weight_type, stream_layers or max_vram. See /options/descriptions on the server for the full list'
+            type: 'string', title: 'Kind',
+            enum: ['checkpoint', 'diffusion', 'vae', 'lora', 'clip', 't5', 'embedding',
+                   'controlnet', 'llm', 'esrgan', 'taesd', 'motion_module', 'adetailer'],
+            default: 'checkpoint',
+            description: 'Which kind of model to choose from. esrgan is the upscaler kind'
         },
-        listType: {
-            type: 'string',
-            title: 'List Type',
-            enum: ['', 'checkpoint', 'diffusion', 'vae', 'lora', 'clip', 't5', 'embedding',
-                'controlnet', 'llm', 'esrgan', 'taesd', 'motion_module', 'adetailer'],
-            default: '',
-            description: 'For the list operation, restrict the result to one kind of model. Empty lists all of them'
+        modelName: {
+            type: 'string', title: 'Model',
+            description: 'Chosen from what the server has. Change the Kind above to list a different sort',
+            dynamicOptions: {
+                source: 'node',
+                node: 'sdcpp-model',
+                config: { listOnly: true },
+                itemsPath: 'models',
+                valueKey: 'name',
+                labelKey: 'name',
+                needs: ['serverUrl', 'credentialId', 'modelType']
+            }
         },
-        search: {
-            type: 'string',
-            title: 'Search',
-            description: 'For the list operation, keep only models whose name contains this text'
+        setting: {
+            type: 'string', title: 'Supplies',
+            description: 'The setting this fills in on the node it is connected to. Leave empty to use the ordinary name for the kind chosen - modelName for a checkpoint or diffusion model, vae, taesd, controlnet, llm and so on'
         },
-        timeout: {
-            type: 'number',
-            title: 'Timeout (ms)',
-            description: 'Loading a large model reads many gigabytes from disk and can take minutes, so this defaults high',
-            default: 300000
-        }
+        timeout: { type: 'number', title: 'Timeout (ms)', default: 60000 }
     },
     required: []
 };
 
-const inputSchema = {
-    type: 'object',
-    properties: {
-        data: { type: 'any' }
-    }
-};
+const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
 
 const outputSchema = {
     type: 'object',
     properties: {
-        operation: { type: 'string' },
-        success: { type: 'boolean' },
-        modelLoaded: { type: 'boolean', description: 'For health, whether a model occupies the main slot' },
-        modelName: { type: 'string' },
+        _config: { type: 'object', description: 'The setting this supplies, as the engine consumes it' },
+        modelName: { type: 'string', description: 'The chosen model' },
         modelType: { type: 'string' },
-        architecture: { type: 'string', description: 'Architecture the server detected, which decides the generation defaults' },
-        upscalerLoaded: { type: 'boolean' },
-        upscalerName: { type: 'string' },
-        loadedComponents: { type: 'object', description: 'Which component files are resident' },
-        models: { type: 'array', description: 'For list, a flat array of {type, name} entries' },
-        modelsByType: { type: 'object', description: 'For list, the raw grouping the server returned' },
-        count: { type: 'number', description: 'For list, how many models matched' },
-        message: { type: 'string' },
-        health: { type: 'object', description: 'For health, the full response including memory and feature flags' }
+        setting: { type: 'string', description: 'Which setting it was supplied as' },
+        models: { type: 'array', description: 'Everything of this kind the server has. Only filled when listing' }
     }
 };
 
+// Which setting a kind of model ordinarily fills in. A checkpoint or a
+// diffusion model is the model itself; the rest are components named after
+// themselves. An upscaler is also "modelName", because SD.cpp Load Upscaler
+// takes it as its own model.
+const SETTING_FOR_KIND = {
+    checkpoint: 'modelName',
+    diffusion: 'modelName',
+    esrgan: 'modelName',
+    vae: 'vae',
+    clip: 'clipL',
+    t5: 't5xxl',
+    llm: 'llm',
+    taesd: 'taesd',
+    controlnet: 'controlnet',
+    motion_module: 'motionModule',
+    lora: 'lora',
+    embedding: 'embedding',
+    adetailer: 'adetailer'
+};
+
+// The server groups models by kind under its own key names.
+const GROUP_FOR_KIND = {
+    checkpoint: 'checkpoints',
+    diffusion: 'diffusion_models',
+    vae: 'vae',
+    lora: 'loras',
+    clip: 'clip',
+    t5: 't5',
+    embedding: 'embeddings',
+    controlnet: 'controlnets',
+    llm: 'llm',
+    esrgan: 'esrgan',
+    taesd: 'taesd',
+    motion_module: 'motion_modules',
+    adetailer: 'adetailers'
+};
+
 function normalizeServer(url) {
     const value = String(url || '').trim();
     if (!value) {
@@ -180,6 +173,17 @@ function login(server, credential, timeout) {
     return session.token;
 }
 
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
 function putIfSet(target, key, value) {
     if (value === undefined || value === null || value === '') {
         return;
@@ -187,277 +191,77 @@ function putIfSet(target, key, value) {
     target[key] = value;
 }
 
-// The list response groups models under one key per kind. Flattening gives a
-// single array a Loop node can walk without knowing the key names.
-const LIST_GROUPS = {
-    checkpoints: 'checkpoint',
-    diffusion_models: 'diffusion',
-    vae: 'vae',
-    loras: 'lora',
-    clip: 'clip',
-    t5: 't5',
-    embeddings: 'embedding',
-    controlnets: 'controlnet',
-    llm: 'llm',
-    esrgan: 'esrgan',
-    taesd: 'taesd',
-    motion_modules: 'motion_module',
-    adetailers: 'adetailer'
-};
+function listModels(server, credentialId, modelType, timeout) {
+    const credential = readCredential(credentialId);
+    const token = login(server, credential, Math.min(timeout, 30000));
 
-function flattenModels(listed) {
-    const flat = [];
-    const groupNames = Object.keys(LIST_GROUPS);
+    const listed = call({
+        method: 'GET',
+        url: server + '/models?type=' + encodeURIComponent(modelType),
+        headers: { 'Authorization': 'Bearer ' + token },
+        timeout: timeout,
+        what: 'listing ' + modelType + ' models'
+    });
 
-    for (let i = 0; i < groupNames.length; i++) {
-        const group = groupNames[i];
-        const entries = listed[group];
-        if (!Array.isArray(entries)) {
-            continue;
-        }
-        for (let j = 0; j < entries.length; j++) {
-            const entry = entries[j];
-            // Entries are sometimes plain file names and sometimes objects
-            // carrying a name plus size and hash, so handle both rather than
-            // assuming one and producing a column of undefined.
-            if (entry && typeof entry === 'object') {
-                flat.push({
-                    type: LIST_GROUPS[group],
-                    name: entry.name || entry.filename || '',
-                    details: entry
-                });
-            } else {
-                flat.push({ type: LIST_GROUPS[group], name: String(entry), details: null });
-            }
+    const group = GROUP_FOR_KIND[modelType] || modelType;
+    const entries = Array.isArray(listed[group]) ? listed[group] : [];
+
+    const models = [];
+    for (let i = 0; i < entries.length; i++) {
+        const entry = entries[i];
+        // Entries are sometimes plain file names and sometimes objects with a
+        // name plus size and hash, so handle both rather than assuming one and
+        // producing a list of undefined.
+        if (entry && typeof entry === 'object') {
+            models.push({ name: entry.name || entry.filename || '', details: entry });
+        } else {
+            models.push({ name: String(entry), details: null });
         }
     }
-    return flat;
+    return { models: models, loadedModel: listed.loaded_model || '' };
 }
 
 async function execute(config, input, context) {
     const server = normalizeServer(config.serverUrl);
-    const operation = config.operation || 'health';
-    const timeout = config.timeout || 300000;
-
-    // /health is the one endpoint the server leaves unauthenticated, which makes
-    // it usable as a reachability check before any credential exists.
-    if (operation === 'health') {
-        const health = call({
-            method: 'GET',
-            url: server + '/health',
-            timeout: timeout,
-            what: 'reading server health'
-        });
-
-        return {
-            operation: operation,
-            success: true,
-            modelLoaded: health.model_loaded === true,
-            modelName: health.model_name || '',
-            modelType: health.model_type || '',
-            architecture: health.model_architecture || '',
-            upscalerLoaded: health.upscaler_loaded === true,
-            upscalerName: health.upscaler_name || '',
-            loadedComponents: health.loaded_components || {},
-            models: [],
-            modelsByType: {},
-            count: 0,
-            message: health.status || '',
-            health: health
-        };
-    }
-
-    if (!config.credentialId) {
-        throw new Error('SD.cpp: the ' + operation + ' operation needs a credential. ' +
-            'Only health works without one');
-    }
-
-    const credential = readCredential(config.credentialId);
-    const token = login(server, credential, timeout);
-    const authHeaders = { 'Authorization': 'Bearer ' + token };
-    const jsonHeaders = {
-        'Authorization': 'Bearer ' + token,
-        'Content-Type': 'application/json'
-    };
-
-    if (operation === 'list') {
-        const query = [];
-        if (config.listType) {
-            query.push('type=' + encodeURIComponent(config.listType));
-        }
-        if (config.search) {
-            query.push('search=' + encodeURIComponent(config.search));
-        }
-
-        const listed = call({
-            method: 'GET',
-            url: server + '/models' + (query.length ? '?' + query.join('&') : ''),
-            headers: authHeaders,
-            timeout: timeout,
-            what: 'listing models'
-        });
-
-        const flat = flattenModels(listed);
-
-        return {
-            operation: operation,
-            success: true,
-            modelLoaded: !!listed.loaded_model,
-            modelName: listed.loaded_model || '',
-            modelType: listed.loaded_model_type || '',
-            architecture: '',
-            upscalerLoaded: false,
-            upscalerName: '',
-            loadedComponents: {},
-            models: flat,
-            modelsByType: listed,
-            count: flat.length,
-            message: '',
-            health: {}
-        };
-    }
-
-    if (operation === 'refresh') {
-        const refreshed = call({
-            method: 'POST',
-            url: server + '/models/refresh',
-            headers: jsonHeaders,
-            body: '{}',
-            timeout: timeout,
-            what: 'rescanning the model directories'
-        });
-
-        return {
-            operation: operation,
-            success: true,
-            modelLoaded: false,
-            modelName: '',
-            modelType: '',
-            architecture: '',
-            upscalerLoaded: false,
-            upscalerName: '',
-            loadedComponents: {},
-            models: [],
-            modelsByType: {},
-            count: 0,
-            message: refreshed.message || 'model directories rescanned',
-            health: {}
-        };
-    }
-
-    if (operation === 'unload' || operation === 'unloadUpscaler') {
-        const path = operation === 'unload' ? '/models/unload' : '/upscaler/unload';
-        const unloaded = call({
-            method: 'POST',
-            url: server + path,
-            headers: jsonHeaders,
-            body: '{}',
-            timeout: timeout,
-            what: operation === 'unload' ? 'unloading the model' : 'unloading the upscaler'
-        });
-
+    const timeout = config.timeout > 0 ? config.timeout : 60000;
+    const modelType = config.modelType || 'checkpoint';
+
+    // The editor asks for the list while a field is being filled in. It is not
+    // what this node does during a run - a workflow that listed 358 models on
+    // every execution would be paying for nothing.
+    if (config.listOnly === true) {
+        const listed = listModels(server, config.credentialId, modelType, timeout);
         return {
-            operation: operation,
-            success: true,
-            modelLoaded: false,
             modelName: '',
-            modelType: '',
-            architecture: '',
-            upscalerLoaded: false,
-            upscalerName: '',
-            loadedComponents: {},
-            models: [],
-            modelsByType: {},
-            count: 0,
-            message: unloaded.message || 'unloaded',
-            health: {}
+            modelType: modelType,
+            setting: '',
+            models: listed.models,
+            loadedModel: listed.loadedModel
         };
     }
 
     const modelName = String(config.modelName || '').trim();
     if (!modelName) {
-        throw new Error('SD.cpp: the ' + operation + ' operation needs a model name');
-    }
-
-    if (operation === 'loadUpscaler') {
-        const body = { model_name: modelName };
-        const loaded = call({
-            method: 'POST',
-            url: server + '/upscaler/load',
-            headers: jsonHeaders,
-            body: JSON.stringify(body),
-            timeout: timeout,
-            what: 'loading upscaler ' + modelName
-        });
-
-        smartbotic.log.info('SD.cpp: loaded upscaler ' + modelName);
-
-        return {
-            operation: operation,
-            success: loaded.success !== false,
-            modelLoaded: false,
-            modelName: modelName,
-            modelType: 'esrgan',
-            architecture: '',
-            upscalerLoaded: true,
-            upscalerName: modelName,
-            loadedComponents: {},
-            models: [],
-            modelsByType: {},
-            count: 0,
-            message: loaded.message || '',
-            health: {}
-        };
-    }
-
-    if (operation !== 'load') {
-        throw new Error('SD.cpp: unknown operation "' + operation + '"');
+        throw new Error('SD.cpp Model: choose a model. Pick one from the list, which is read ' +
+            'from the server named above');
     }
 
-    const body = { model_name: modelName };
-    putIfSet(body, 'model_type', config.modelType);
-    putIfSet(body, 'vae', config.vae);
-    putIfSet(body, 'clip_l', config.clipL);
-    putIfSet(body, 'clip_g', config.clipG);
-    putIfSet(body, 't5xxl', config.t5xxl);
-    putIfSet(body, 'llm', config.llm);
-    putIfSet(body, 'taesd', config.taesd);
-    putIfSet(body, 'controlnet', config.controlnet);
+    const setting = String(config.setting || '').trim() ||
+                    SETTING_FOR_KIND[modelType] || 'modelName';
 
-    if (config.options && typeof config.options === 'object') {
-        body.options = config.options;
-    }
-
-    // Loading a model unloads whatever was in the slot first, and the server
-    // holds a mutex for the duration, so this call blocks until the weights are
-    // resident. That is why the timeout defaults to five minutes.
-    const loaded = call({
-        method: 'POST',
-        url: server + '/models/load',
-        headers: jsonHeaders,
-        body: JSON.stringify(body),
-        timeout: timeout,
-        what: 'loading model ' + modelName
-    });
+    const values = {};
+    values[setting] = modelName;
 
-    smartbotic.log.info('SD.cpp: loaded model ' + modelName +
-        (loaded.model_type ? ' as ' + loaded.model_type : ''));
+    smartbotic.log.info('SD.cpp Model: supplying ' + setting + ' = ' + modelName);
 
+    // Same marker the Configurator uses, so this reaches a node through its
+    // config input and replaces that node's own setting.
     return {
-        operation: operation,
-        success: loaded.success !== false,
-        modelLoaded: true,
-        modelName: loaded.model_name || modelName,
-        modelType: loaded.model_type || config.modelType || '',
-        architecture: '',
-        upscalerLoaded: false,
-        upscalerName: '',
-        loadedComponents: loaded.loaded_components || {},
-        models: [],
-        modelsByType: {},
-        count: 0,
-        message: loaded.message || '',
-        health: {}
+        _config: values,
+        modelName: modelName,
+        modelType: modelType,
+        setting: setting,
+        models: []
     };
 }
 

+ 192 - 0
nodes/sdcpp/sdcpp-unload.js

@@ -0,0 +1,192 @@
+/**
+ * @node sdcpp-unload
+ * @name SD.cpp Unload
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Free the main model slot, the upscaler slot, or both
+ * @icon eraser
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        target: {
+            type: 'string', title: 'Unload',
+            enum: ['model', 'upscaler', 'both'],
+            default: 'model',
+            description: 'Which slot to free. They are independent - unloading the model leaves the upscaler in place, and the other way round'
+        },
+        timeout: { type: 'number', title: 'Timeout (ms)', default: 60000 }
+    },
+    required: []
+};
+
+const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        unloadedModel: { type: 'boolean' },
+        unloadedUpscaler: { type: 'boolean' },
+        previousModel: { type: 'string', description: 'What was in the main slot before' },
+        previousUpscaler: { type: 'string' }
+    }
+};
+
+function normalizeServer(url) {
+    const value = String(url || '').trim();
+    if (!value) {
+        throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
+    }
+    return value.replace(/\/+$/, '');
+}
+
+function readCredential(credentialId) {
+    const auth = smartbotic.credentials.get(credentialId);
+    if (!auth || auth.success !== true) {
+        throw new Error('SD.cpp: could not read the credential: ' +
+            ((auth && auth.error) || 'unknown error'));
+    }
+
+    const value = auth.headerValue || '';
+    if (value.indexOf('Basic ') !== 0) {
+        throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' +
+            'username and password');
+    }
+
+    const decoded = smartbotic.utils.base64Decode(value.substring(6));
+    const separator = decoded.indexOf(':');
+    if (separator < 1) {
+        throw new Error('SD.cpp: the credential is malformed, expected a username and a password');
+    }
+
+    return {
+        username: decoded.substring(0, separator),
+        password: decoded.substring(separator + 1)
+    };
+}
+
+function call(options) {
+    const response = smartbotic.http.request(options);
+
+    let body = response.data;
+    if (typeof body === 'string' && body.length > 0) {
+        try {
+            body = JSON.parse(body);
+        } catch (e) {
+            const snippet = body.substring(0, 200).replace(/\s+/g, ' ');
+            throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status +
+                ' with a body that is not JSON: ' + snippet);
+        }
+    }
+
+    if (response.status < 200 || response.status >= 300) {
+        const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status);
+        throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail);
+    }
+
+    return body || {};
+}
+
+function login(server, credential, timeout) {
+    const session = call({
+        method: 'POST',
+        url: server + '/auth/login',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+            username: credential.username,
+            password: credential.password
+        }),
+        timeout: timeout,
+        what: 'signing in'
+    });
+
+    if (!session.token) {
+        throw new Error('SD.cpp: the server accepted the login but returned no token');
+    }
+    return session.token;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout > 0 ? config.timeout : 60000;
+    const target = config.target || 'model';
+
+    const health = readHealth(server, Math.min(timeout, 15000));
+    const hadModel = health.model_loaded === true;
+    const hadUpscaler = health.upscaler_loaded === true;
+
+    const wantModel = target === 'model' || target === 'both';
+    const wantUpscaler = target === 'upscaler' || target === 'both';
+
+    // Nothing to free is a success, not an error. A cleanup step at the end of a
+    // workflow should not fail because an earlier branch already unloaded.
+    if ((!wantModel || !hadModel) && (!wantUpscaler || !hadUpscaler)) {
+        smartbotic.log.info('SD.cpp: nothing to unload');
+        return {
+            unloadedModel: false,
+            unloadedUpscaler: false,
+            previousModel: '',
+            previousUpscaler: ''
+        };
+    }
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, Math.min(timeout, 30000));
+    const headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
+
+    let unloadedModel = false;
+    let unloadedUpscaler = false;
+
+    if (wantModel && hadModel) {
+        call({ method: 'POST', url: server + '/models/unload', headers: headers,
+               body: '{}', timeout: timeout, what: 'unloading the model' });
+        unloadedModel = true;
+    }
+    if (wantUpscaler && hadUpscaler) {
+        call({ method: 'POST', url: server + '/upscaler/unload', headers: headers,
+               body: '{}', timeout: timeout, what: 'unloading the upscaler' });
+        unloadedUpscaler = true;
+    }
+
+    smartbotic.log.info('SD.cpp: unloaded' +
+        (unloadedModel ? ' model ' + (health.model_name || '') : '') +
+        (unloadedUpscaler ? ' upscaler ' + (health.upscaler_name || '') : ''));
+
+    return {
+        unloadedModel: unloadedModel,
+        unloadedUpscaler: unloadedUpscaler,
+        previousModel: unloadedModel ? (health.model_name || '') : '',
+        previousUpscaler: unloadedUpscaler ? (health.upscaler_name || '') : ''
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 192 - 0
nodes/sdcpp/sdcpp-upscaler-load.js

@@ -0,0 +1,192 @@
+/**
+ * @node sdcpp-upscaler-load
+ * @name SD.cpp Load Upscaler
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Make sure an upscaler is loaded, without reloading one that already is
+ * @icon maximize
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string', title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password',
+            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+        },
+        modelName: {
+            type: 'string', title: 'Upscaler',
+            description: 'File name of the ESRGAN-family upscaler. Usually supplied by an SD.cpp Model node set to type esrgan'
+        },
+        force: {
+            type: 'boolean', title: 'Force Reload',
+            default: false,
+            description: 'Load again even when this upscaler is already loaded'
+        },
+        timeout: { type: 'number', title: 'Timeout (ms)', default: 120000 }
+    },
+    required: []
+};
+
+const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        upscalerName: { type: 'string' },
+        loaded: { type: 'boolean', description: 'True when this node performed a load' },
+        alreadyLoaded: { type: 'boolean' },
+        previousUpscaler: { type: 'string' },
+        elapsedMs: { type: 'number' }
+    }
+};
+
+function normalizeServer(url) {
+    const value = String(url || '').trim();
+    if (!value) {
+        throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
+    }
+    return value.replace(/\/+$/, '');
+}
+
+function readCredential(credentialId) {
+    const auth = smartbotic.credentials.get(credentialId);
+    if (!auth || auth.success !== true) {
+        throw new Error('SD.cpp: could not read the credential: ' +
+            ((auth && auth.error) || 'unknown error'));
+    }
+
+    const value = auth.headerValue || '';
+    if (value.indexOf('Basic ') !== 0) {
+        throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' +
+            'username and password');
+    }
+
+    const decoded = smartbotic.utils.base64Decode(value.substring(6));
+    const separator = decoded.indexOf(':');
+    if (separator < 1) {
+        throw new Error('SD.cpp: the credential is malformed, expected a username and a password');
+    }
+
+    return {
+        username: decoded.substring(0, separator),
+        password: decoded.substring(separator + 1)
+    };
+}
+
+function call(options) {
+    const response = smartbotic.http.request(options);
+
+    let body = response.data;
+    if (typeof body === 'string' && body.length > 0) {
+        try {
+            body = JSON.parse(body);
+        } catch (e) {
+            const snippet = body.substring(0, 200).replace(/\s+/g, ' ');
+            throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status +
+                ' with a body that is not JSON: ' + snippet);
+        }
+    }
+
+    if (response.status < 200 || response.status >= 300) {
+        const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status);
+        throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail);
+    }
+
+    return body || {};
+}
+
+function login(server, credential, timeout) {
+    const session = call({
+        method: 'POST',
+        url: server + '/auth/login',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+            username: credential.username,
+            password: credential.password
+        }),
+        timeout: timeout,
+        what: 'signing in'
+    });
+
+    if (!session.token) {
+        throw new Error('SD.cpp: the server accepted the login but returned no token');
+    }
+    return session.token;
+}
+
+// /health is unauthenticated, and it is the only way to find out what is
+// already loaded without asking for a token first.
+function readHealth(server, timeout) {
+    return call({
+        method: 'GET',
+        url: server + '/health',
+        timeout: timeout,
+        what: 'reading server health'
+    });
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    target[key] = value;
+}
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout > 0 ? config.timeout : 120000;
+    const modelName = String(config.modelName || '').trim();
+
+    if (!modelName) {
+        throw new Error('SD.cpp: an upscaler name is required. Connect an SD.cpp Model node ' +
+            'set to type esrgan, or type the file name');
+    }
+
+    const startedAt = Date.now();
+    const health = readHealth(server, Math.min(timeout, 15000));
+    const current = health.upscaler_name || '';
+
+    // The upscaler lives in its own slot, independent of the main model, so
+    // loading one does not disturb whatever is generating.
+    if (current === modelName && config.force !== true) {
+        smartbotic.log.info('SD.cpp: upscaler ' + modelName + ' is already loaded');
+        return {
+            upscalerName: current,
+            loaded: false,
+            alreadyLoaded: true,
+            previousUpscaler: '',
+            elapsedMs: Date.now() - startedAt
+        };
+    }
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, Math.min(timeout, 30000));
+
+    call({
+        method: 'POST',
+        url: server + '/upscaler/load',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        body: JSON.stringify({ model_name: modelName }),
+        timeout: timeout,
+        what: 'loading upscaler ' + modelName
+    });
+
+    smartbotic.log.info('SD.cpp: loaded upscaler ' + modelName);
+
+    return {
+        upscalerName: modelName,
+        loaded: true,
+        alreadyLoaded: false,
+        previousUpscaler: current === modelName ? '' : current,
+        elapsedMs: Date.now() - startedAt
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 0 - 14
tests/nodes/sdcpp-model-health.json

@@ -1,14 +0,0 @@
-{
-  "name": "verify-sdcpp-model-health",
-  "nodes": [
-    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
-    {"id": "hp", "name": "Health", "type": "sdcpp-model", "position": {"x": 0, "y": 100},
-     "config": {"serverUrl": "http://mulan:8077", "operation": "health"}}
-  ],
-  "connections": [
-    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "hp", "targetInput": "data"}
-  ],
-  "expect": {
-    "hp": {"status": "completed", "output": {"operation": "health", "success": true}}
-  }
-}

+ 17 - 0
tests/nodes/sdcpp-model-load-assert.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-sdcpp-model-load-assert",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "load", "name": "Load", "type": "sdcpp-model-load", "position": {"x": 0, "y": 120},
+     "config": {"serverUrl": "http://mulan:8077",
+                "credentialId": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1",
+                "modelName": "SD1x/definitely-not-loaded.safetensors",
+                "whenDifferent": "fail"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "data"}
+  ],
+  "expect": {
+    "load": {"status": "failed", "errorContains": "expects"}
+  }
+}

+ 26 - 0
tests/nodes/sdcpp-model-load-idempotent.json

@@ -0,0 +1,26 @@
+{
+  "name": "verify-sdcpp-model-load-idempotent",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "srv", "name": "SD.cpp Server", "type": "configurator", "position": {"x": -260, "y": 100},
+     "config": {"label": "SD.cpp Server", "settings": [
+       {"name": "serverUrl", "value": "http://mulan:8077"},
+       {"name": "credentialId", "value": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1"}
+     ]}},
+    {"id": "pick", "name": "Model", "type": "sdcpp-model", "position": {"x": -260, "y": 220},
+     "config": {"modelType": "checkpoint", "modelName": "SD1x/AnythingXL_v50.safetensors"}},
+    {"id": "load", "name": "Load", "type": "sdcpp-model-load", "position": {"x": 0, "y": 320},
+     "config": {}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "data"},
+    {"sourceNodeId": "srv", "sourceOutput": "main", "targetNodeId": "pick", "targetInput": "config"},
+    {"sourceNodeId": "srv", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "config"},
+    {"sourceNodeId": "pick", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "config"}
+  ],
+  "expectStatus": "completed",
+  "expect": {
+    "load": {"status": "completed", "output": {"alreadyLoaded": true, "loaded": false,
+             "modelName": "SD1x/AnythingXL_v50.safetensors"}}
+  }
+}