sdcpp-health.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. /**
  2. * @node sdcpp-health
  3. * @name SD.cpp Health
  4. * @category sdcpp
  5. * @version 1.0.0
  6. * @description Check whether an sdcpp-restapi server is reachable and what it currently has loaded
  7. * @icon heart-pulse
  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, such as http://localhost:8077',
  16. default: 'http://localhost:8077'
  17. },
  18. failIfUnreachable: {
  19. type: 'boolean',
  20. title: 'Fail If Unreachable',
  21. description: 'Throw when the server cannot be reached. Leave off to return reachable false and branch on it, which is the point of a health check',
  22. default: false
  23. },
  24. requireModelLoaded: {
  25. type: 'boolean',
  26. title: 'Require A Loaded Model',
  27. description: 'Treat a reachable server with no model in its slot as unhealthy. A generation job would be rejected in that state, so a check that only pings the server would pass right before the real work fails',
  28. default: false
  29. },
  30. requireUpscalerLoaded: {
  31. type: 'boolean',
  32. title: 'Require A Loaded Upscaler',
  33. description: 'Treat a reachable server with no upscaler loaded as unhealthy. Only upscale jobs need one',
  34. default: false
  35. },
  36. timeout: {
  37. type: 'number',
  38. title: 'Timeout (ms)',
  39. description: 'A health check should give up quickly - it exists to tell you the server is not answering',
  40. default: 5000
  41. }
  42. },
  43. required: []
  44. };
  45. const inputSchema = {
  46. type: 'object',
  47. properties: {
  48. data: { type: 'any' }
  49. }
  50. };
  51. const outputSchema = {
  52. type: 'object',
  53. properties: {
  54. healthy: { type: 'boolean', description: 'Reachable and meeting whichever requirements were asked for' },
  55. reachable: { type: 'boolean', description: 'The server answered at all' },
  56. status: { type: 'string', description: 'The status the server reports about itself' },
  57. error: { type: 'string', description: 'Why the check failed, empty when healthy' },
  58. responseMs: { type: 'number', description: 'How long the server took to answer' },
  59. modelLoaded: { type: 'boolean' },
  60. modelName: { type: 'string' },
  61. modelType: { type: 'string' },
  62. architecture: { type: 'string', description: 'Architecture the server detected, which decides generation defaults' },
  63. modelLoading: { type: 'boolean', description: 'True while a model is still being read from disk' },
  64. loadingModelName: { type: 'string' },
  65. loadingProgress: { type: 'number', description: 'Load progress from 0 to 100, or -1 when nothing is loading' },
  66. upscalerLoaded: { type: 'boolean' },
  67. upscalerName: { type: 'string' },
  68. loadedComponents: { type: 'object' },
  69. memory: { type: 'object', description: 'Memory snapshot the server reports' },
  70. features: { type: 'object', description: 'Feature flags, including whether authentication is required' },
  71. version: { type: 'string' },
  72. gitCommit: { type: 'string' },
  73. lastError: { type: 'string', description: 'The last error the server itself recorded, if any' }
  74. }
  75. };
  76. function normalizeServer(url) {
  77. const value = String(url || '').trim();
  78. if (!value) {
  79. throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
  80. }
  81. return value.replace(/\/+$/, '');
  82. }
  83. function unhealthy(server, reason, responseMs) {
  84. return {
  85. healthy: false,
  86. reachable: false,
  87. status: '',
  88. error: reason,
  89. responseMs: responseMs,
  90. modelLoaded: false,
  91. modelName: '',
  92. modelType: '',
  93. architecture: '',
  94. modelLoading: false,
  95. loadingModelName: '',
  96. loadingProgress: -1,
  97. upscalerLoaded: false,
  98. upscalerName: '',
  99. loadedComponents: {},
  100. memory: {},
  101. features: {},
  102. version: '',
  103. gitCommit: '',
  104. lastError: ''
  105. };
  106. }
  107. async function execute(config, input, context) {
  108. const server = normalizeServer(config.serverUrl);
  109. const timeout = config.timeout > 0 ? config.timeout : 5000;
  110. const startedAt = Date.now();
  111. // /health is the one endpoint sdcpp-restapi leaves unauthenticated, which
  112. // is what makes this usable as a reachability probe with no credential.
  113. let response;
  114. try {
  115. response = smartbotic.http.request({
  116. method: 'GET',
  117. url: server + '/health',
  118. timeout: timeout
  119. });
  120. } catch (e) {
  121. // A refused connection or a DNS failure throws rather than returning a
  122. // status. That is the single most useful thing a health check can
  123. // report, so it must not escape as a node error unless asked for.
  124. const result = unhealthy(server, 'Could not reach ' + server + ': ' + (e.message || e),
  125. Date.now() - startedAt);
  126. if (config.failIfUnreachable) {
  127. throw new Error('SD.cpp health: ' + result.error);
  128. }
  129. smartbotic.log.warn('SD.cpp health: ' + result.error);
  130. return result;
  131. }
  132. const responseMs = Date.now() - startedAt;
  133. if (!response || response.status < 200 || response.status >= 300) {
  134. const status = response ? response.status : 0;
  135. const result = unhealthy(server, 'Server answered with HTTP ' + status, responseMs);
  136. // It answered, so it is reachable - just not well. Keeping those apart
  137. // matters: a 500 is a broken server, a refused connection is a missing
  138. // one, and they call for different responses.
  139. result.reachable = status > 0;
  140. if (config.failIfUnreachable) {
  141. throw new Error('SD.cpp health: ' + result.error);
  142. }
  143. smartbotic.log.warn('SD.cpp health: ' + result.error);
  144. return result;
  145. }
  146. let health = response.data;
  147. if (typeof health === 'string') {
  148. try {
  149. health = JSON.parse(health);
  150. } catch (e) {
  151. const result = unhealthy(server, 'The server answered with something that is not JSON',
  152. responseMs);
  153. result.reachable = true;
  154. if (config.failIfUnreachable) {
  155. throw new Error('SD.cpp health: ' + result.error);
  156. }
  157. return result;
  158. }
  159. }
  160. health = health || {};
  161. const loadingStep = health.loading_step;
  162. const loadingTotal = health.loading_total_steps;
  163. const result = {
  164. healthy: true,
  165. reachable: true,
  166. status: health.status || '',
  167. error: '',
  168. responseMs: responseMs,
  169. modelLoaded: health.model_loaded === true,
  170. modelName: health.model_name || '',
  171. modelType: health.model_type || '',
  172. architecture: health.model_architecture || '',
  173. modelLoading: health.model_loading === true,
  174. loadingModelName: health.loading_model_name || '',
  175. loadingProgress: (loadingTotal > 0 && loadingStep !== undefined)
  176. ? Math.round((loadingStep / loadingTotal) * 100)
  177. : -1,
  178. upscalerLoaded: health.upscaler_loaded === true,
  179. upscalerName: health.upscaler_name || '',
  180. loadedComponents: health.loaded_components || {},
  181. memory: health.memory || {},
  182. features: health.features || {},
  183. version: health.version || '',
  184. gitCommit: health.git_commit || '',
  185. lastError: health.last_error ? String(health.last_error) : ''
  186. };
  187. // A server that answers but has nothing loaded will reject a generation
  188. // job. Without these checks a health node would go green immediately before
  189. // the work it was guarding fails.
  190. const problems = [];
  191. if (config.requireModelLoaded && !result.modelLoaded) {
  192. problems.push(result.modelLoading
  193. ? 'a model is still loading (' + result.loadingModelName + ')'
  194. : 'no model is loaded');
  195. }
  196. if (config.requireUpscalerLoaded && !result.upscalerLoaded) {
  197. problems.push('no upscaler is loaded');
  198. }
  199. if (problems.length > 0) {
  200. result.healthy = false;
  201. result.error = 'Server is reachable but ' + problems.join(' and ');
  202. if (config.failIfUnreachable) {
  203. throw new Error('SD.cpp health: ' + result.error);
  204. }
  205. smartbotic.log.warn('SD.cpp health: ' + result.error);
  206. return result;
  207. }
  208. smartbotic.log.info('SD.cpp health: ' + server + ' healthy in ' + responseMs + 'ms' +
  209. (result.modelLoaded ? ', model ' + result.modelName + ' (' + result.architecture + ')'
  210. : ', no model loaded'));
  211. return result;
  212. }
  213. module.exports = { configSchema, inputSchema, outputSchema, execute };