/** * @node sdcpp-model * @name SD.cpp Model * @category sdcpp * @version 2.0.0 * @description Pick a model from what the server has, and hand it to whichever node needs one * @icon layers */ // The credential this node wants, named so it can be found. It is stored as a // plain basic credential - that is what decides how it is encrypted - and this // only says which basic credential is the SD.cpp one. Anything that accepts a // basic credential still accepts this, and this node still accepts a plain // basic credential, because the shape is identical. const credentialTypes = [ { id: 'sdcpp', label: 'SD.cpp Server', baseType: 'basic', description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call', usernameLabel: 'Username', passwordLabel: 'Password' } ]; const configSchema = { type: 'object', properties: { serverUrl: { 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. Listing models needs one', dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } } }, source: { type: 'string', title: 'Model', enum: ['choose', 'loaded'], enumLabels: ['Pick one from the server', 'Whatever is loaded right now'], default: 'choose', description: 'Pick one, or take the model the server currently has loaded. Taking the loaded one keeps a workflow portable between servers, and means it never spends minutes swapping a model just to match what a node was set to' }, includeComponents: { type: 'boolean', title: 'Include Components', default: false, showWhen: { field: 'source', value: 'loaded' }, description: 'Also supply the VAE, CLIP, T5, LLM and TAESD the server has loaded, not only the model name. Only useful for a diffusion model, which loads its parts separately' }, modelType: { type: 'string', title: 'Kind', showWhen: { field: 'source', value: 'choose' }, 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' }, modelName: { type: 'string', title: 'Choose', showWhen: { field: 'source', value: 'choose' }, 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'] } }, 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)', default: 60000 } }, required: [] }; const inputSchema = { type: 'object', properties: { data: { type: 'any' } } }; const outputSchema = { type: 'object', properties: { _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: 'What the server detected, when following the loaded model' }, loadedComponents: { type: 'object', description: 'The components the server has loaded, when following it' }, 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' }; // What the server calls each loaded component, and the setting it fills in on a // node that loads one. Reported under several spellings across versions, so each // setting lists the ones seen. const COMPONENT_SETTINGS = [ { setting: 'vae', keys: ['vae', 'vae_model'] }, { setting: 'clipL', keys: ['clip_l', 'clipL'] }, { setting: 'clipG', keys: ['clip_g', 'clipG'] }, { setting: 't5xxl', keys: ['t5xxl', 't5'] }, { setting: 'llm', keys: ['llm'] }, { setting: 'taesd', keys: ['taesd'] }, { setting: 'controlnet', keys: ['controlnet', 'controlnet_model'] } ]; // 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) { 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; } function listModels(server, credentialId, modelType, timeout) { const credential = readCredential(credentialId); const token = login(server, credential, Math.min(timeout, 30000)); const listed = call({ method: 'GET', url: server + '/models?type=' + encodeURIComponent(modelType), headers: { 'Authorization': 'Bearer ' + token }, timeout: timeout, what: 'listing ' + modelType + ' models' }); 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 { models: models, loadedModel: listed.loaded_model || '' }; } async function execute(config, input, context) { const server = normalizeServer(config.serverUrl); 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 { modelName: '', modelType: modelType, setting: '', models: listed.models, loadedModel: listed.loadedModel }; } let modelName; let architecture = ''; let loadedComponents = {}; const values = {}; if ((config.source || 'choose') === 'loaded') { // /health is unauthenticated, so this works before any credential is // set up - useful when the point is to follow whatever the server has. const health = readHealth(server, Math.min(timeout, 15000)); if (health.model_loaded !== true || !health.model_name) { throw new Error('SD.cpp Model: this node is set to use the model the server has ' + 'loaded, but ' + server + ' has none. Load one first, or choose a model here'); } modelName = health.model_name; architecture = health.model_architecture || ''; loadedComponents = health.loaded_components || {}; if (config.includeComponents === true) { for (let i = 0; i < COMPONENT_SETTINGS.length; i++) { const entry = COMPONENT_SETTINGS[i]; for (let k = 0; k < entry.keys.length; k++) { const found = loadedComponents[entry.keys[k]]; // A component the server does not have comes back empty or // absent; supplying that would blank the target's own value. if (found) { values[entry.setting] = found; break; } } } } } else { modelName = String(config.modelName || '').trim(); if (!modelName) { throw new Error('SD.cpp Model: choose a model. Pick one from the list, which is read ' + 'from the server named above'); } } const setting = String(config.setting || '').trim() || SETTING_FOR_KIND[modelType] || 'modelName'; values[setting] = modelName; 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 { _config: values, modelName: modelName, modelType: modelType, architecture: architecture, loadedComponents: loadedComponents, setting: setting, models: [] }; } module.exports = { configSchema, inputSchema, outputSchema, execute };