Просмотр исходного кода

Merge branch 'sdcpp-model-nodes': one SD.cpp node per job

fszontagh 1 месяц назад
Родитель
Сommit
5e8831627c

+ 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"}}
+  }
+}