Przeglądaj źródła

feat: nodes for the sdcpp-restapi image and video generation server

Five nodes under nodes/sdcpp/, one per step rather than one that does
everything, because sdcpp-restapi's queue persists a job's state. A render
survives between executions, so submitting, waiting and collecting can be
separate nodes that a workflow composes as it likes - submit now and collect in
a later run, poll from a different branch, or hand the job id to a person.

- sdcpp-generate      queue a txt2img, img2img, txt2vid or upscale job -> jobId
- sdcpp-job-status    read one job's state without waiting
- sdcpp-job-wait      poll until the job is terminal, with its own timeout
- sdcpp-fetch-output  download the produced files, optionally into storage
- sdcpp-model         list, load and unload models, and read server health

Three details worth knowing:

Generation fields are architecture defaults. The server fills width, steps,
cfg_scale, sampler and scheduler from the loaded model's preset when the field
is absent - 4 steps for Flux Schnell, 20 for SDXL, cfg 1.0 for Z-Image. Sending
an empty box as 0 or "" would override that preset with nonsense, so a field
the user did not fill is left out of the body entirely. The emptiness test is
explicitly not a truthy one: seed 0 and clip_skip 0 are legitimate values that a
truthy check would drop, the same way || 0 dropped the TTL defaults.

Only /health is unauthenticated. Everything else needs a bearer token from
/auth/login, so each node reads a basic credential and signs in. The wait node
signs in once and reuses the token across every poll rather than per tick.

Outputs are binary. sdcpp-fetch-output reads response.dataBase64, not
response.data - PNG and MP4 bytes are not valid UTF-8 and would be corrupted by
a string read. It also encodes output paths per segment, since a path looks like
"<job-id>/image_0.png" and encoding it whole would turn the separator into %2F.

Generation fields beyond the common ones are reachable through an extraOptions
passthrough on generate and an options passthrough on model load, so a new
server field does not need a node change. GET /options/generation on the server
documents every one of them.

Verified: all five register and their schemas parse (23 config keys on generate,
enums and dynamicOptions intact), and tests/nodes/sdcpp-model-health.json runs
health against a live server and passes. Full suite 43/43.

Not yet verified end to end: everything behind authentication. This machine has
no sdcpp-restapi credential, so generate, wait, fetch-output and the model
load/unload paths are written against the OpenAPI schema and API.md but have not
been run against the server.
fszontagh 1 miesiąc temu
rodzic
commit
99817d7673

+ 343 - 0
nodes/sdcpp/sdcpp-fetch-output.js

@@ -0,0 +1,343 @@
+/**
+ * @node sdcpp-fetch-output
+ * @name SD.cpp Fetch Output
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Download the images or videos a finished sdcpp-restapi job produced
+ * @icon download
+ */
+
+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'] }
+            }
+        },
+        paths: {
+            type: 'array',
+            title: 'Output Paths',
+            description: 'Relative output paths from a finished job, normally {{$json.outputs}} from an SD.cpp Wait For Job node. A comma separated string works too',
+            items: { type: 'string' }
+        },
+        limit: {
+            type: 'number',
+            title: 'Limit',
+            description: 'Download at most this many of the paths given. 0 means all of them',
+            default: 0
+        },
+        storeInDatabase: {
+            type: 'boolean',
+            title: 'Store In Database',
+            description: 'Also save each file to a storage collection, so a later execution can read it back by id',
+            default: false
+        },
+        collection: {
+            type: 'string',
+            title: 'Collection',
+            description: 'Storage collection to save into. Must not begin with an underscore, those silently lose writes',
+            default: 'sdcpp_outputs'
+        },
+        storageTtlHours: {
+            type: 'number',
+            title: 'Storage TTL (hours)',
+            description: 'How long a stored file is kept before it expires. Set to 0 to keep it forever. Defaults to 24 hours',
+            default: 24
+        },
+        timeout: {
+            type: 'number',
+            title: 'Timeout (ms)',
+            description: 'Per file. A large video can take a while to transfer',
+            default: 120000
+        }
+    },
+    required: ['credentialId']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        count: { type: 'number', description: 'How many files were downloaded' },
+        files: {
+            type: 'array',
+            description: 'One entry per file, each a binary file object other nodes accept'
+        },
+        file: {
+            type: 'object',
+            description: 'The first file, for the common case of a single image feeding a node that takes one'
+        },
+        totalBytes: { type: 'number' },
+        storage: { type: 'array', description: 'Collection and id of each stored file, when storing was enabled' }
+    }
+};
+
+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;
+}
+
+function toPathList(value) {
+    if (value === undefined || value === null || value === '') {
+        return [];
+    }
+    if (Array.isArray(value)) {
+        const list = [];
+        for (let i = 0; i < value.length; i++) {
+            const entry = String(value[i] || '').trim();
+            if (entry) {
+                list.push(entry);
+            }
+        }
+        return list;
+    }
+    return String(value).split(',')
+        .map(function (entry) { return entry.trim(); })
+        .filter(function (entry) { return entry.length > 0; });
+}
+
+const MIME_BY_EXTENSION = {
+    png: 'image/png',
+    jpg: 'image/jpeg',
+    jpeg: 'image/jpeg',
+    webp: 'image/webp',
+    gif: 'image/gif',
+    mp4: 'video/mp4',
+    webm: 'video/webm'
+};
+
+function guessMimeType(path, headerValue) {
+    // The server's own Content-Type wins when it sends one that says anything.
+    // A generic octet-stream tells us nothing an extension cannot tell us better.
+    const header = String(headerValue || '').split(';')[0].trim().toLowerCase();
+    if (header && header !== 'application/octet-stream') {
+        return header;
+    }
+
+    const dot = path.lastIndexOf('.');
+    if (dot !== -1) {
+        const extension = path.substring(dot + 1).toLowerCase();
+        if (MIME_BY_EXTENSION[extension]) {
+            return MIME_BY_EXTENSION[extension];
+        }
+    }
+    return 'application/octet-stream';
+}
+
+function headerLookup(headers, name) {
+    if (!headers) {
+        return '';
+    }
+    // Header casing is not guaranteed, so match without it rather than hoping
+    // for a particular spelling of Content-Type.
+    const wanted = name.toLowerCase();
+    const keys = Object.keys(headers);
+    for (let i = 0; i < keys.length; i++) {
+        if (keys[i].toLowerCase() === wanted) {
+            return headers[keys[i]];
+        }
+    }
+    return '';
+}
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 120000;
+
+    let paths = toPathList(config.paths);
+    if (paths.length === 0) {
+        throw new Error('SD.cpp: no output paths were given. Pass the outputs array from a ' +
+            'finished job, such as {{$json.outputs}}');
+    }
+
+    const limit = config.limit > 0 ? config.limit : 0;
+    if (limit > 0 && paths.length > limit) {
+        smartbotic.log.info('SD.cpp: downloading the first ' + limit + ' of ' + paths.length +
+            ' outputs, the rest were left on the server');
+        paths = paths.slice(0, limit);
+    }
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const files = [];
+    const storage = [];
+    let totalBytes = 0;
+
+    for (let i = 0; i < paths.length; i++) {
+        const path = paths[i];
+
+        // Each path segment is encoded separately: outputs look like
+        // "<job-id>/image_0.png" and encoding the whole string would turn the
+        // separator into %2F and break the lookup.
+        const encoded = path.split('/').map(function (segment) {
+            return encodeURIComponent(segment);
+        }).join('/');
+
+        const response = smartbotic.http.request({
+            method: 'GET',
+            url: server + '/output/' + encoded,
+            headers: { 'Authorization': 'Bearer ' + token },
+            timeout: timeout
+        });
+
+        if (response.status < 200 || response.status >= 300) {
+            throw new Error('SD.cpp: downloading "' + path + '" failed with HTTP ' +
+                response.status);
+        }
+
+        // dataBase64, not data: these are PNG and MP4 bytes, and reading them as
+        // a string would corrupt anything that is not valid UTF-8.
+        const base64Data = response.dataBase64 || '';
+        if (!base64Data) {
+            throw new Error('SD.cpp: the server returned an empty body for "' + path + '"');
+        }
+
+        // Base64 encodes three bytes per four characters, less the padding.
+        let size = Math.floor(base64Data.length / 4) * 3;
+        if (base64Data.charAt(base64Data.length - 1) === '=') {
+            size--;
+        }
+        if (base64Data.charAt(base64Data.length - 2) === '=') {
+            size--;
+        }
+
+        const filename = path.substring(path.lastIndexOf('/') + 1);
+        const mimeType = guessMimeType(path, headerLookup(response.headers, 'content-type'));
+
+        const fileObject = {
+            type: 'binary',
+            data: base64Data,
+            mimeType: mimeType,
+            filename: filename,
+            size: size,
+            path: path,
+            url: server + '/output/' + encoded
+        };
+
+        files.push(fileObject);
+        totalBytes += size;
+
+        if (config.storeInDatabase) {
+            const collection = config.collection || 'sdcpp_outputs';
+            const ttlHours = config.storageTtlHours ?? 24;
+            const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
+
+            const insertResult = smartbotic.storage.insert(collection, {
+                filename: filename,
+                mimeType: mimeType,
+                size: size,
+                sourcePath: path,
+                sourceUrl: fileObject.url,
+                downloadedAt: Date.now(),
+                data: base64Data
+            }, null, ttlMs);
+
+            if (!insertResult.success) {
+                throw new Error('SD.cpp: downloaded "' + path + '" but storing it in ' +
+                    collection + ' failed: ' + (insertResult.error || 'unknown error'));
+            }
+
+            storage.push({ collection: collection, id: insertResult.id });
+        }
+    }
+
+    smartbotic.log.info('SD.cpp: downloaded ' + files.length + ' output file(s), ' +
+        totalBytes + ' bytes');
+
+    return {
+        count: files.length,
+        files: files,
+        file: files[0],
+        totalBytes: totalBytes,
+        storage: storage
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 355 - 0
nodes/sdcpp/sdcpp-generate.js

@@ -0,0 +1,355 @@
+/**
+ * @node sdcpp-generate
+ * @name SD.cpp Generate
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Queue an image or video generation job on an sdcpp-restapi server and return its job id
+ * @icon image-plus
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        serverUrl: {
+            type: 'string',
+            title: 'Server URL',
+            description: 'Base address of the sdcpp-restapi server, such as http://localhost:8077. No trailing slash needed',
+            default: 'http://localhost:8077'
+        },
+        credentialId: {
+            type: 'string',
+            title: 'Credential',
+            description: 'A basic credential holding the sdcpp-restapi username and password. Every endpoint except health requires one',
+            dynamicOptions: {
+                source: 'credentials',
+                filter: { type: ['basic'] }
+            }
+        },
+        operation: {
+            type: 'string',
+            title: 'Operation',
+            enum: ['txt2img', 'img2img', 'txt2vid', 'upscale'],
+            default: 'txt2img',
+            description: 'txt2img generates from a prompt alone, img2img and txt2vid start from an image, upscale enlarges one using the separately loaded upscaler'
+        },
+        prompt: {
+            type: 'string',
+            title: 'Prompt',
+            description: 'What to generate. Supports {{variable}} interpolation and <lora:name:weight> syntax. Also supports {a|b|c} dynamic prompts, which the server expands into one queue item per variation',
+            format: 'textarea'
+        },
+        negativePrompt: {
+            type: 'string',
+            title: 'Negative Prompt',
+            description: 'Concepts to exclude. Has no effect on LCM and one-step SDXS models',
+            format: 'textarea'
+        },
+        width: {
+            type: 'number',
+            title: 'Width',
+            description: 'Leave empty to use the loaded architecture default, such as 1024 for SDXL and Flux'
+        },
+        height: {
+            type: 'number',
+            title: 'Height',
+            description: 'Leave empty to use the loaded architecture default'
+        },
+        steps: {
+            type: 'number',
+            title: 'Steps',
+            description: 'Sampling steps. Leave empty to use the architecture default, which is 4 for Flux Schnell and LCM and 20 for most others'
+        },
+        cfgScale: {
+            type: 'number',
+            title: 'CFG Scale',
+            description: 'Guidance strength. Leave empty to use the architecture default. Distilled models such as Flux Schnell, LCM and Z-Image want 1.0'
+        },
+        sampler: {
+            type: 'string',
+            title: 'Sampler',
+            enum: ['', 'euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2',
+                'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd', 'res_multistep', 'res_2s',
+                'er_sde', 'euler_cfg_pp', 'euler_a_cfg_pp', 'euler_ge'],
+            default: '',
+            description: 'Leave empty to use the architecture default'
+        },
+        scheduler: {
+            type: 'string',
+            title: 'Scheduler',
+            enum: ['', 'discrete', 'karras', 'exponential', 'ays', 'gits', 'sgm_uniform',
+                'simple', 'smoothstep', 'kl_optimal', 'lcm', 'bong_tangent', 'ltx2'],
+            default: '',
+            description: 'Leave empty to use the architecture default'
+        },
+        seed: {
+            type: 'number',
+            title: 'Seed',
+            description: 'Use -1 for a random seed. Any other value reproduces the same image. Leave empty to let the server decide'
+        },
+        batchCount: {
+            type: 'number',
+            title: 'Batch Count',
+            description: 'How many images to generate in this one job, all sharing the prompt'
+        },
+        clipSkip: {
+            type: 'number',
+            title: 'CLIP Skip',
+            description: 'Skip the last N CLIP layers. Negative means the model default'
+        },
+        title: {
+            type: 'string',
+            title: 'Job Title',
+            description: 'Optional label stored with the job, useful for finding it again in the queue'
+        },
+        initImageBase64: {
+            type: 'string',
+            title: 'Init Image (base64)',
+            description: 'Starting image for img2img and txt2vid, and the image to enlarge for upscale. Base64 without a data: prefix. Usually {{$json.file.data}} from a previous node'
+        },
+        maskImageBase64: {
+            type: 'string',
+            title: 'Mask Image (base64)',
+            description: 'Inpainting mask for img2img. White marks the area to regenerate'
+        },
+        strength: {
+            type: 'number',
+            title: 'Strength',
+            description: 'How far img2img and txt2vid may move from the init image, from 0 to 1. Higher changes more'
+        },
+        upscaleFactor: {
+            type: 'number',
+            title: 'Upscale Factor',
+            description: 'For the upscale operation. The upscaler model must already be loaded'
+        },
+        videoFrames: {
+            type: 'number',
+            title: 'Video Frames',
+            description: 'For txt2vid, how many frames to render'
+        },
+        fps: {
+            type: 'number',
+            title: 'FPS',
+            description: 'For txt2vid, frames per second of the output video'
+        },
+        extraOptions: {
+            type: 'object',
+            title: 'Extra Options',
+            description: 'Any other generation field passed straight through to the server, such as slg_scale, cache_mode, ref_images or vae_tiling. Names must match the API exactly. See /options/generation on the server for the full list with descriptions'
+        },
+        timeout: {
+            type: 'number',
+            title: 'Timeout (ms)',
+            description: 'Applies to queueing the job, not to the render itself. The call returns as soon as the job is accepted',
+            default: 30000
+        }
+    },
+    required: ['credentialId']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        jobId: { type: 'string', description: 'Id of the queued job, to pass to SD.cpp Job Status or Wait' },
+        status: { type: 'string', description: 'Queue status at the moment of accepting, normally pending' },
+        position: { type: 'number', description: 'Place in the queue when it was accepted' },
+        operation: { type: 'string', description: 'Which generation endpoint was called' },
+        request: { type: 'object', description: 'The body actually sent, useful for debugging which defaults were left to the server' }
+    }
+};
+
+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;
+}
+
+// Width, steps, cfg_scale and friends are architecture defaults: the server
+// fills them from the loaded model's preset when the field is absent. Sending
+// an empty box as 0 or "" would override that preset with nonsense, so a field
+// the user did not fill must not appear in the body at all.
+//
+// The test is emptiness, never truthiness. seed 0 and clip_skip 0 are both
+// legitimate values that a truthy check would silently drop.
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    if (typeof value === 'number' && !isFinite(value)) {
+        return;
+    }
+    target[key] = value;
+}
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const operation = config.operation || 'txt2img';
+    const timeout = config.timeout || 30000;
+
+    if (['txt2img', 'img2img', 'txt2vid', 'upscale'].indexOf(operation) === -1) {
+        throw new Error('SD.cpp: unknown operation "' + operation + '"');
+    }
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const body = {};
+
+    if (operation === 'upscale') {
+        // Upscale is not a generation call - it takes an image and the upscaler
+        // slot, and none of the prompt or sampler fields apply.
+        putIfSet(body, 'image_base64', config.initImageBase64);
+        if (!body.image_base64) {
+            throw new Error('SD.cpp: the upscale operation needs an init image, ' +
+                'given as base64 in the Init Image field');
+        }
+        putIfSet(body, 'upscale_factor', config.upscaleFactor);
+        putIfSet(body, 'title', config.title);
+    } else {
+        putIfSet(body, 'prompt', config.prompt);
+        if (!body.prompt) {
+            throw new Error('SD.cpp: ' + operation + ' needs a prompt');
+        }
+        putIfSet(body, 'negative_prompt', config.negativePrompt);
+        putIfSet(body, 'width', config.width);
+        putIfSet(body, 'height', config.height);
+        putIfSet(body, 'steps', config.steps);
+        putIfSet(body, 'cfg_scale', config.cfgScale);
+        putIfSet(body, 'sampler', config.sampler);
+        putIfSet(body, 'scheduler', config.scheduler);
+        putIfSet(body, 'seed', config.seed);
+        putIfSet(body, 'batch_count', config.batchCount);
+        putIfSet(body, 'clip_skip', config.clipSkip);
+        putIfSet(body, 'title', config.title);
+
+        if (operation === 'img2img' || operation === 'txt2vid') {
+            putIfSet(body, 'init_image_base64', config.initImageBase64);
+            putIfSet(body, 'strength', config.strength);
+        }
+        if (operation === 'img2img') {
+            putIfSet(body, 'mask_image_base64', config.maskImageBase64);
+        }
+        if (operation === 'txt2vid') {
+            putIfSet(body, 'video_frames', config.videoFrames);
+            putIfSet(body, 'fps', config.fps);
+        }
+        if (operation === 'img2img' && !body.init_image_base64) {
+            throw new Error('SD.cpp: img2img needs an init image, given as base64 in the ' +
+                'Init Image field');
+        }
+    }
+
+    // The API has around seventy generation fields and this node exposes the
+    // common ones. Anything else the user names here goes straight through, so
+    // a new server field does not need a node change to be reachable.
+    const extra = config.extraOptions;
+    if (extra && typeof extra === 'object') {
+        const keys = Object.keys(extra);
+        for (let i = 0; i < keys.length; i++) {
+            putIfSet(body, keys[i], extra[keys[i]]);
+        }
+    }
+
+    const queued = call({
+        method: 'POST',
+        url: server + '/' + operation,
+        headers: {
+            'Content-Type': 'application/json',
+            'Authorization': 'Bearer ' + token
+        },
+        body: JSON.stringify(body),
+        timeout: timeout,
+        what: 'queueing the ' + operation + ' job'
+    });
+
+    if (!queued.job_id) {
+        throw new Error('SD.cpp: the ' + operation + ' job was accepted but no job id came back');
+    }
+
+    smartbotic.log.info('SD.cpp: queued ' + operation + ' job ' + queued.job_id +
+        ' at position ' + (queued.position !== undefined ? queued.position : 'unknown'));
+
+    return {
+        jobId: queued.job_id,
+        status: queued.status || 'pending',
+        position: queued.position !== undefined ? queued.position : -1,
+        operation: operation,
+        request: body
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 217 - 0
nodes/sdcpp/sdcpp-job-status.js

@@ -0,0 +1,217 @@
+/**
+ * @node sdcpp-job-status
+ * @name SD.cpp Job Status
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Read the current state of one sdcpp-restapi job without waiting for it
+ * @icon activity
+ */
+
+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'] }
+            }
+        },
+        jobId: {
+            type: 'string',
+            title: 'Job ID',
+            description: 'Id of the job to inspect, normally {{$json.jobId}} from an SD.cpp Generate node'
+        },
+        failOnJobFailure: {
+            type: 'boolean',
+            title: 'Fail If The Job Failed',
+            description: 'Throw when the job is in the failed state, instead of returning it as data. Leave off to branch on the status yourself',
+            default: false
+        },
+        timeout: {
+            type: 'number',
+            title: 'Timeout (ms)',
+            default: 30000
+        }
+    },
+    required: ['credentialId']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        jobId: { type: 'string' },
+        status: { type: 'string', description: 'pending, processing, completed, failed, cancelled or deleted' },
+        type: { type: 'string', description: 'txt2img, img2img, txt2vid, upscale, convert, model_download or model_hash' },
+        done: { type: 'boolean', description: 'True once the job reached a state it cannot leave on its own' },
+        succeeded: { type: 'boolean', description: 'True only for a completed job' },
+        step: { type: 'number', description: 'Sampling steps finished so far' },
+        totalSteps: { type: 'number' },
+        percent: { type: 'number', description: 'Progress from 0 to 100, or -1 when the server reports no total' },
+        outputs: { type: 'array', description: 'Relative output paths, empty until the job completes' },
+        urls: { type: 'array', description: 'The same outputs as full URLs on this server' },
+        error: { type: 'string', description: 'Failure reason when the job failed' },
+        createdAt: { type: 'string' },
+        startedAt: { type: 'string' },
+        completedAt: { type: 'string' },
+        params: { type: 'object', description: 'Generation parameters the job actually ran with, including the resolved seed' },
+        modelSettings: { type: 'object', description: 'Which model and components produced it' }
+    }
+};
+
+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;
+}
+
+// A job the worker will not move again on its own. "deleted" counts: a job in
+// the recycle bin is not coming back without someone restoring it, so a wait
+// that treated it as pending would spin until its timeout.
+const TERMINAL = ['completed', 'failed', 'cancelled', 'deleted'];
+
+function shapeJob(job, server) {
+    const status = job.status || 'unknown';
+    const progress = job.progress || {};
+    const step = progress.step !== undefined ? progress.step : 0;
+    const totalSteps = progress.total_steps !== undefined ? progress.total_steps : 0;
+    const outputs = job.outputs || [];
+
+    const urls = [];
+    for (let i = 0; i < outputs.length; i++) {
+        urls.push(server + '/output/' + outputs[i]);
+    }
+
+    return {
+        jobId: job.job_id || '',
+        status: status,
+        type: job.type || '',
+        done: TERMINAL.indexOf(status) !== -1,
+        succeeded: status === 'completed',
+        step: step,
+        totalSteps: totalSteps,
+        percent: totalSteps > 0 ? Math.round((step / totalSteps) * 100) : -1,
+        outputs: outputs,
+        urls: urls,
+        error: job.error || '',
+        createdAt: job.created_at || '',
+        startedAt: job.started_at || '',
+        completedAt: job.completed_at || '',
+        params: job.params || {},
+        modelSettings: job.model_settings || {}
+    };
+}
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 30000;
+
+    const jobId = String(config.jobId || '').trim();
+    if (!jobId) {
+        throw new Error('SD.cpp: a job id is required');
+    }
+
+    const credential = readCredential(config.credentialId);
+    const token = login(server, credential, timeout);
+
+    const job = call({
+        method: 'GET',
+        url: server + '/queue/' + encodeURIComponent(jobId),
+        headers: { 'Authorization': 'Bearer ' + token },
+        timeout: timeout,
+        what: 'reading job ' + jobId
+    });
+
+    const result = shapeJob(job, server);
+
+    if (config.failOnJobFailure && result.status === 'failed') {
+        throw new Error('SD.cpp: job ' + jobId + ' failed: ' + (result.error || 'no reason given'));
+    }
+
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 282 - 0
nodes/sdcpp/sdcpp-job-wait.js

@@ -0,0 +1,282 @@
+/**
+ * @node sdcpp-job-wait
+ * @name SD.cpp Wait For Job
+ * @category sdcpp
+ * @version 1.0.0
+ * @description Poll an sdcpp-restapi job until it finishes, fails or the wait runs out
+ * @icon hourglass
+ */
+
+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'] }
+            }
+        },
+        jobId: {
+            type: 'string',
+            title: 'Job ID',
+            description: 'Id of the job to wait for, normally {{$json.jobId}} from an SD.cpp Generate node'
+        },
+        pollInterval: {
+            type: 'number',
+            title: 'Poll Interval (ms)',
+            description: 'How long to pause between status checks. The server renders serially, so polling faster than a second gains nothing',
+            default: 2000
+        },
+        maxWait: {
+            type: 'number',
+            title: 'Maximum Wait (ms)',
+            description: 'Give up after this long and report the job as still running. This holds one execution slot for the whole wait, so keep it close to how long a render really takes on this server',
+            default: 600000
+        },
+        failOnTimeout: {
+            type: 'boolean',
+            title: 'Fail If The Wait Runs Out',
+            description: 'Throw when the maximum wait passes and the job is still going. Leave off to return the unfinished job and decide in the workflow. The job itself keeps running either way',
+            default: true
+        },
+        failOnJobFailure: {
+            type: 'boolean',
+            title: 'Fail If The Job Failed',
+            description: 'Throw when the job ends in the failed or cancelled state, instead of returning it as data',
+            default: true
+        },
+        timeout: {
+            type: 'number',
+            title: 'Request Timeout (ms)',
+            description: 'Timeout for each individual status request, not for the wait as a whole',
+            default: 30000
+        }
+    },
+    required: ['credentialId']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        jobId: { type: 'string' },
+        status: { type: 'string' },
+        type: { type: 'string' },
+        done: { type: 'boolean', description: 'False only when the wait ran out while the job was still going' },
+        succeeded: { type: 'boolean' },
+        timedOut: { type: 'boolean', description: 'True when the maximum wait passed before the job finished' },
+        step: { type: 'number' },
+        totalSteps: { type: 'number' },
+        percent: { type: 'number' },
+        outputs: { type: 'array', description: 'Relative output paths, ready for an SD.cpp Fetch Output node' },
+        urls: { type: 'array' },
+        error: { type: 'string' },
+        waitedMs: { type: 'number', description: 'How long this node actually waited' },
+        polls: { type: 'number', description: 'How many status requests it made' },
+        params: { type: 'object' },
+        modelSettings: { type: 'object' }
+    }
+};
+
+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;
+}
+
+const TERMINAL = ['completed', 'failed', 'cancelled', 'deleted'];
+
+function shapeJob(job, server) {
+    const status = job.status || 'unknown';
+    const progress = job.progress || {};
+    const step = progress.step !== undefined ? progress.step : 0;
+    const totalSteps = progress.total_steps !== undefined ? progress.total_steps : 0;
+    const outputs = job.outputs || [];
+
+    const urls = [];
+    for (let i = 0; i < outputs.length; i++) {
+        urls.push(server + '/output/' + outputs[i]);
+    }
+
+    return {
+        jobId: job.job_id || '',
+        status: status,
+        type: job.type || '',
+        done: TERMINAL.indexOf(status) !== -1,
+        succeeded: status === 'completed',
+        step: step,
+        totalSteps: totalSteps,
+        percent: totalSteps > 0 ? Math.round((step / totalSteps) * 100) : -1,
+        outputs: outputs,
+        urls: urls,
+        error: job.error || '',
+        createdAt: job.created_at || '',
+        startedAt: job.started_at || '',
+        completedAt: job.completed_at || '',
+        params: job.params || {},
+        modelSettings: job.model_settings || {}
+    };
+}
+
+async function execute(config, input, context) {
+    const server = normalizeServer(config.serverUrl);
+    const timeout = config.timeout || 30000;
+    const pollInterval = config.pollInterval > 0 ? config.pollInterval : 2000;
+    const maxWait = config.maxWait > 0 ? config.maxWait : 600000;
+
+    const jobId = String(config.jobId || '').trim();
+    if (!jobId) {
+        throw new Error('SD.cpp: a job id is required');
+    }
+
+    const credential = readCredential(config.credentialId);
+
+    // Signing in once and reusing the token for every poll. A long render can
+    // mean hundreds of checks, and logging in for each one would triple the
+    // request count for no reason.
+    const token = login(server, credential, timeout);
+
+    const startedAt = Date.now();
+    let polls = 0;
+    let result = null;
+
+    while (true) {
+        const job = call({
+            method: 'GET',
+            url: server + '/queue/' + encodeURIComponent(jobId),
+            headers: { 'Authorization': 'Bearer ' + token },
+            timeout: timeout,
+            what: 'reading job ' + jobId
+        });
+
+        polls++;
+        result = shapeJob(job, server);
+
+        if (result.done) {
+            break;
+        }
+
+        // Check the clock before sleeping rather than after, so the node cannot
+        // overshoot the maximum wait by a whole poll interval.
+        const elapsed = Date.now() - startedAt;
+        if (elapsed + pollInterval >= maxWait) {
+            result.timedOut = true;
+            break;
+        }
+
+        smartbotic.utils.sleep(pollInterval);
+    }
+
+    result.waitedMs = Date.now() - startedAt;
+    result.polls = polls;
+    result.timedOut = result.timedOut === true;
+
+    if (result.timedOut) {
+        // The job is untouched and still queued on the server. Reporting the id
+        // back means a later run can pick it up rather than re-rendering it.
+        smartbotic.log.warn('SD.cpp: stopped waiting for job ' + jobId + ' after ' +
+            result.waitedMs + 'ms, it is still ' + result.status);
+        if (config.failOnTimeout !== false) {
+            throw new Error('SD.cpp: job ' + jobId + ' was still ' + result.status +
+                ' after ' + result.waitedMs + 'ms. It is still running on the server; ' +
+                'raise the maximum wait or check it again later with SD.cpp Job Status');
+        }
+        return result;
+    }
+
+    smartbotic.log.info('SD.cpp: job ' + jobId + ' finished as ' + result.status +
+        ' after ' + result.waitedMs + 'ms and ' + polls + ' polls');
+
+    if (config.failOnJobFailure !== false &&
+        (result.status === 'failed' || result.status === 'cancelled')) {
+        throw new Error('SD.cpp: job ' + jobId + ' ended as ' + result.status + ': ' +
+            (result.error || 'no reason given'));
+    }
+
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 464 - 0
nodes/sdcpp/sdcpp-model.js

@@ -0,0 +1,464 @@
+/**
+ * @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
+ * @icon layers
+ */
+
+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. 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'
+        },
+        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'
+        },
+        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'
+        },
+        search: {
+            type: 'string',
+            title: 'Search',
+            description: 'For the list operation, keep only models whose name contains this text'
+        },
+        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
+        }
+    },
+    required: []
+};
+
+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' },
+        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' }
+    }
+};
+
+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;
+}
+
+function putIfSet(target, key, value) {
+    if (value === undefined || value === null || value === '') {
+        return;
+    }
+    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 flattenModels(listed) {
+    const flat = [];
+    const groupNames = Object.keys(LIST_GROUPS);
+
+    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 });
+            }
+        }
+    }
+    return flat;
+}
+
+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'
+        });
+
+        return {
+            operation: operation,
+            success: true,
+            modelLoaded: false,
+            modelName: '',
+            modelType: '',
+            architecture: '',
+            upscalerLoaded: false,
+            upscalerName: '',
+            loadedComponents: {},
+            models: [],
+            modelsByType: {},
+            count: 0,
+            message: unloaded.message || 'unloaded',
+            health: {}
+        };
+    }
+
+    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 + '"');
+    }
+
+    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;
+    }
+
+    // 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
+    });
+
+    smartbotic.log.info('SD.cpp: loaded model ' + modelName +
+        (loaded.model_type ? ' as ' + loaded.model_type : ''));
+
+    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: {}
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

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

@@ -0,0 +1,14 @@
+{
+  "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}}
+  }
+}