sdcpp-model.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. /**
  2. * @node sdcpp-model
  3. * @name SD.cpp Model
  4. * @category sdcpp
  5. * @version 1.0.0
  6. * @description List, load or unload models on an sdcpp-restapi server, and read its health
  7. * @icon layers
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. serverUrl: {
  13. type: 'string',
  14. title: 'Server URL',
  15. description: 'Base address of the sdcpp-restapi server',
  16. default: 'http://localhost:8077'
  17. },
  18. credentialId: {
  19. type: 'string',
  20. title: 'Credential',
  21. description: 'A basic credential holding the sdcpp-restapi username and password. Not needed for the health operation, which the server leaves open',
  22. dynamicOptions: {
  23. source: 'credentials',
  24. filter: { type: ['basic'] }
  25. }
  26. },
  27. operation: {
  28. type: 'string',
  29. title: 'Operation',
  30. enum: ['health', 'list', 'load', 'unload', 'loadUpscaler', 'unloadUpscaler', 'refresh'],
  31. default: 'health',
  32. 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'
  33. },
  34. modelName: {
  35. type: 'string',
  36. title: 'Model Name',
  37. description: 'File name of the model to load, relative to its type directory. Required for load and loadUpscaler'
  38. },
  39. modelType: {
  40. type: 'string',
  41. title: 'Model Type',
  42. enum: ['', 'checkpoint', 'diffusion'],
  43. default: '',
  44. 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'
  45. },
  46. vae: { type: 'string', title: 'VAE', description: 'Component file name, for a diffusion model' },
  47. clipL: { type: 'string', title: 'CLIP-L', description: 'Component file name' },
  48. clipG: { type: 'string', title: 'CLIP-G', description: 'Component file name' },
  49. t5xxl: { type: 'string', title: 'T5-XXL', description: 'Component file name' },
  50. llm: { type: 'string', title: 'LLM', description: 'Component file name, used by Z-Image, Qwen, Anima and Flux2' },
  51. taesd: { type: 'string', title: 'TAESD', description: 'Tiny autoencoder used to render progress previews, not final output' },
  52. controlnet: { type: 'string', title: 'ControlNet', description: 'Component file name' },
  53. options: {
  54. type: 'object',
  55. title: 'Load Options',
  56. 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'
  57. },
  58. listType: {
  59. type: 'string',
  60. title: 'List Type',
  61. enum: ['', 'checkpoint', 'diffusion', 'vae', 'lora', 'clip', 't5', 'embedding',
  62. 'controlnet', 'llm', 'esrgan', 'taesd', 'motion_module', 'adetailer'],
  63. default: '',
  64. description: 'For the list operation, restrict the result to one kind of model. Empty lists all of them'
  65. },
  66. search: {
  67. type: 'string',
  68. title: 'Search',
  69. description: 'For the list operation, keep only models whose name contains this text'
  70. },
  71. timeout: {
  72. type: 'number',
  73. title: 'Timeout (ms)',
  74. description: 'Loading a large model reads many gigabytes from disk and can take minutes, so this defaults high',
  75. default: 300000
  76. }
  77. },
  78. required: []
  79. };
  80. const inputSchema = {
  81. type: 'object',
  82. properties: {
  83. data: { type: 'any' }
  84. }
  85. };
  86. const outputSchema = {
  87. type: 'object',
  88. properties: {
  89. operation: { type: 'string' },
  90. success: { type: 'boolean' },
  91. modelLoaded: { type: 'boolean', description: 'For health, whether a model occupies the main slot' },
  92. modelName: { type: 'string' },
  93. modelType: { type: 'string' },
  94. architecture: { type: 'string', description: 'Architecture the server detected, which decides the generation defaults' },
  95. upscalerLoaded: { type: 'boolean' },
  96. upscalerName: { type: 'string' },
  97. loadedComponents: { type: 'object', description: 'Which component files are resident' },
  98. models: { type: 'array', description: 'For list, a flat array of {type, name} entries' },
  99. modelsByType: { type: 'object', description: 'For list, the raw grouping the server returned' },
  100. count: { type: 'number', description: 'For list, how many models matched' },
  101. message: { type: 'string' },
  102. health: { type: 'object', description: 'For health, the full response including memory and feature flags' }
  103. }
  104. };
  105. function normalizeServer(url) {
  106. const value = String(url || '').trim();
  107. if (!value) {
  108. throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
  109. }
  110. return value.replace(/\/+$/, '');
  111. }
  112. function readCredential(credentialId) {
  113. const auth = smartbotic.credentials.get(credentialId);
  114. if (!auth || auth.success !== true) {
  115. throw new Error('SD.cpp: could not read the credential: ' +
  116. ((auth && auth.error) || 'unknown error'));
  117. }
  118. const value = auth.headerValue || '';
  119. if (value.indexOf('Basic ') !== 0) {
  120. throw new Error('SD.cpp: the credential must be a basic one, holding the sdcpp-restapi ' +
  121. 'username and password');
  122. }
  123. const decoded = smartbotic.utils.base64Decode(value.substring(6));
  124. const separator = decoded.indexOf(':');
  125. if (separator < 1) {
  126. throw new Error('SD.cpp: the credential is malformed, expected a username and a password');
  127. }
  128. return {
  129. username: decoded.substring(0, separator),
  130. password: decoded.substring(separator + 1)
  131. };
  132. }
  133. function call(options) {
  134. const response = smartbotic.http.request(options);
  135. let body = response.data;
  136. if (typeof body === 'string' && body.length > 0) {
  137. try {
  138. body = JSON.parse(body);
  139. } catch (e) {
  140. const snippet = body.substring(0, 200).replace(/\s+/g, ' ');
  141. throw new Error('SD.cpp: ' + options.what + ' returned HTTP ' + response.status +
  142. ' with a body that is not JSON: ' + snippet);
  143. }
  144. }
  145. if (response.status < 200 || response.status >= 300) {
  146. const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status);
  147. throw new Error('SD.cpp: ' + options.what + ' failed: ' + detail);
  148. }
  149. return body || {};
  150. }
  151. function login(server, credential, timeout) {
  152. const session = call({
  153. method: 'POST',
  154. url: server + '/auth/login',
  155. headers: { 'Content-Type': 'application/json' },
  156. body: JSON.stringify({
  157. username: credential.username,
  158. password: credential.password
  159. }),
  160. timeout: timeout,
  161. what: 'signing in'
  162. });
  163. if (!session.token) {
  164. throw new Error('SD.cpp: the server accepted the login but returned no token');
  165. }
  166. return session.token;
  167. }
  168. function putIfSet(target, key, value) {
  169. if (value === undefined || value === null || value === '') {
  170. return;
  171. }
  172. target[key] = value;
  173. }
  174. // The list response groups models under one key per kind. Flattening gives a
  175. // single array a Loop node can walk without knowing the key names.
  176. const LIST_GROUPS = {
  177. checkpoints: 'checkpoint',
  178. diffusion_models: 'diffusion',
  179. vae: 'vae',
  180. loras: 'lora',
  181. clip: 'clip',
  182. t5: 't5',
  183. embeddings: 'embedding',
  184. controlnets: 'controlnet',
  185. llm: 'llm',
  186. esrgan: 'esrgan',
  187. taesd: 'taesd',
  188. motion_modules: 'motion_module',
  189. adetailers: 'adetailer'
  190. };
  191. function flattenModels(listed) {
  192. const flat = [];
  193. const groupNames = Object.keys(LIST_GROUPS);
  194. for (let i = 0; i < groupNames.length; i++) {
  195. const group = groupNames[i];
  196. const entries = listed[group];
  197. if (!Array.isArray(entries)) {
  198. continue;
  199. }
  200. for (let j = 0; j < entries.length; j++) {
  201. const entry = entries[j];
  202. // Entries are sometimes plain file names and sometimes objects
  203. // carrying a name plus size and hash, so handle both rather than
  204. // assuming one and producing a column of undefined.
  205. if (entry && typeof entry === 'object') {
  206. flat.push({
  207. type: LIST_GROUPS[group],
  208. name: entry.name || entry.filename || '',
  209. details: entry
  210. });
  211. } else {
  212. flat.push({ type: LIST_GROUPS[group], name: String(entry), details: null });
  213. }
  214. }
  215. }
  216. return flat;
  217. }
  218. async function execute(config, input, context) {
  219. const server = normalizeServer(config.serverUrl);
  220. const operation = config.operation || 'health';
  221. const timeout = config.timeout || 300000;
  222. // /health is the one endpoint the server leaves unauthenticated, which makes
  223. // it usable as a reachability check before any credential exists.
  224. if (operation === 'health') {
  225. const health = call({
  226. method: 'GET',
  227. url: server + '/health',
  228. timeout: timeout,
  229. what: 'reading server health'
  230. });
  231. return {
  232. operation: operation,
  233. success: true,
  234. modelLoaded: health.model_loaded === true,
  235. modelName: health.model_name || '',
  236. modelType: health.model_type || '',
  237. architecture: health.model_architecture || '',
  238. upscalerLoaded: health.upscaler_loaded === true,
  239. upscalerName: health.upscaler_name || '',
  240. loadedComponents: health.loaded_components || {},
  241. models: [],
  242. modelsByType: {},
  243. count: 0,
  244. message: health.status || '',
  245. health: health
  246. };
  247. }
  248. if (!config.credentialId) {
  249. throw new Error('SD.cpp: the ' + operation + ' operation needs a credential. ' +
  250. 'Only health works without one');
  251. }
  252. const credential = readCredential(config.credentialId);
  253. const token = login(server, credential, timeout);
  254. const authHeaders = { 'Authorization': 'Bearer ' + token };
  255. const jsonHeaders = {
  256. 'Authorization': 'Bearer ' + token,
  257. 'Content-Type': 'application/json'
  258. };
  259. if (operation === 'list') {
  260. const query = [];
  261. if (config.listType) {
  262. query.push('type=' + encodeURIComponent(config.listType));
  263. }
  264. if (config.search) {
  265. query.push('search=' + encodeURIComponent(config.search));
  266. }
  267. const listed = call({
  268. method: 'GET',
  269. url: server + '/models' + (query.length ? '?' + query.join('&') : ''),
  270. headers: authHeaders,
  271. timeout: timeout,
  272. what: 'listing models'
  273. });
  274. const flat = flattenModels(listed);
  275. return {
  276. operation: operation,
  277. success: true,
  278. modelLoaded: !!listed.loaded_model,
  279. modelName: listed.loaded_model || '',
  280. modelType: listed.loaded_model_type || '',
  281. architecture: '',
  282. upscalerLoaded: false,
  283. upscalerName: '',
  284. loadedComponents: {},
  285. models: flat,
  286. modelsByType: listed,
  287. count: flat.length,
  288. message: '',
  289. health: {}
  290. };
  291. }
  292. if (operation === 'refresh') {
  293. const refreshed = call({
  294. method: 'POST',
  295. url: server + '/models/refresh',
  296. headers: jsonHeaders,
  297. body: '{}',
  298. timeout: timeout,
  299. what: 'rescanning the model directories'
  300. });
  301. return {
  302. operation: operation,
  303. success: true,
  304. modelLoaded: false,
  305. modelName: '',
  306. modelType: '',
  307. architecture: '',
  308. upscalerLoaded: false,
  309. upscalerName: '',
  310. loadedComponents: {},
  311. models: [],
  312. modelsByType: {},
  313. count: 0,
  314. message: refreshed.message || 'model directories rescanned',
  315. health: {}
  316. };
  317. }
  318. if (operation === 'unload' || operation === 'unloadUpscaler') {
  319. const path = operation === 'unload' ? '/models/unload' : '/upscaler/unload';
  320. const unloaded = call({
  321. method: 'POST',
  322. url: server + path,
  323. headers: jsonHeaders,
  324. body: '{}',
  325. timeout: timeout,
  326. what: operation === 'unload' ? 'unloading the model' : 'unloading the upscaler'
  327. });
  328. return {
  329. operation: operation,
  330. success: true,
  331. modelLoaded: false,
  332. modelName: '',
  333. modelType: '',
  334. architecture: '',
  335. upscalerLoaded: false,
  336. upscalerName: '',
  337. loadedComponents: {},
  338. models: [],
  339. modelsByType: {},
  340. count: 0,
  341. message: unloaded.message || 'unloaded',
  342. health: {}
  343. };
  344. }
  345. const modelName = String(config.modelName || '').trim();
  346. if (!modelName) {
  347. throw new Error('SD.cpp: the ' + operation + ' operation needs a model name');
  348. }
  349. if (operation === 'loadUpscaler') {
  350. const body = { model_name: modelName };
  351. const loaded = call({
  352. method: 'POST',
  353. url: server + '/upscaler/load',
  354. headers: jsonHeaders,
  355. body: JSON.stringify(body),
  356. timeout: timeout,
  357. what: 'loading upscaler ' + modelName
  358. });
  359. smartbotic.log.info('SD.cpp: loaded upscaler ' + modelName);
  360. return {
  361. operation: operation,
  362. success: loaded.success !== false,
  363. modelLoaded: false,
  364. modelName: modelName,
  365. modelType: 'esrgan',
  366. architecture: '',
  367. upscalerLoaded: true,
  368. upscalerName: modelName,
  369. loadedComponents: {},
  370. models: [],
  371. modelsByType: {},
  372. count: 0,
  373. message: loaded.message || '',
  374. health: {}
  375. };
  376. }
  377. if (operation !== 'load') {
  378. throw new Error('SD.cpp: unknown operation "' + operation + '"');
  379. }
  380. const body = { model_name: modelName };
  381. putIfSet(body, 'model_type', config.modelType);
  382. putIfSet(body, 'vae', config.vae);
  383. putIfSet(body, 'clip_l', config.clipL);
  384. putIfSet(body, 'clip_g', config.clipG);
  385. putIfSet(body, 't5xxl', config.t5xxl);
  386. putIfSet(body, 'llm', config.llm);
  387. putIfSet(body, 'taesd', config.taesd);
  388. putIfSet(body, 'controlnet', config.controlnet);
  389. if (config.options && typeof config.options === 'object') {
  390. body.options = config.options;
  391. }
  392. // Loading a model unloads whatever was in the slot first, and the server
  393. // holds a mutex for the duration, so this call blocks until the weights are
  394. // resident. That is why the timeout defaults to five minutes.
  395. const loaded = call({
  396. method: 'POST',
  397. url: server + '/models/load',
  398. headers: jsonHeaders,
  399. body: JSON.stringify(body),
  400. timeout: timeout,
  401. what: 'loading model ' + modelName
  402. });
  403. smartbotic.log.info('SD.cpp: loaded model ' + modelName +
  404. (loaded.model_type ? ' as ' + loaded.model_type : ''));
  405. return {
  406. operation: operation,
  407. success: loaded.success !== false,
  408. modelLoaded: true,
  409. modelName: loaded.model_name || modelName,
  410. modelType: loaded.model_type || config.modelType || '',
  411. architecture: '',
  412. upscalerLoaded: false,
  413. upscalerName: '',
  414. loadedComponents: loaded.loaded_components || {},
  415. models: [],
  416. modelsByType: {},
  417. count: 0,
  418. message: loaded.message || '',
  419. health: {}
  420. };
  421. }
  422. module.exports = { configSchema, inputSchema, outputSchema, execute };