/** * @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 };