sdcpp-health.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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. loadOptions: { type: 'object', description: 'The settings the loaded model was loaded with - flash attention, streaming, VRAM budget and the rest' },
  70. memory: { type: 'object', description: 'Memory snapshot the server reports' },
  71. features: { type: 'object', description: 'Feature flags, including whether authentication is required' },
  72. version: { type: 'string' },
  73. gitCommit: { type: 'string' },
  74. lastError: { type: 'string', description: 'The last error the server itself recorded, if any' }
  75. }
  76. };
  77. function normalizeServer(url) {
  78. const value = String(url || '').trim();
  79. if (!value) {
  80. throw new Error('SD.cpp: a server URL is required, such as http://localhost:8077');
  81. }
  82. return value.replace(/\/+$/, '');
  83. }
  84. function unhealthy(server, reason, responseMs) {
  85. return {
  86. healthy: false,
  87. reachable: false,
  88. status: '',
  89. error: reason,
  90. responseMs: responseMs,
  91. modelLoaded: false,
  92. modelName: '',
  93. modelType: '',
  94. architecture: '',
  95. modelLoading: false,
  96. loadingModelName: '',
  97. loadingProgress: -1,
  98. upscalerLoaded: false,
  99. upscalerName: '',
  100. loadedComponents: {},
  101. loadOptions: {},
  102. memory: {},
  103. features: {},
  104. version: '',
  105. gitCommit: '',
  106. lastError: ''
  107. };
  108. }
  109. async function execute(config, input, context) {
  110. const server = normalizeServer(config.serverUrl);
  111. const timeout = config.timeout > 0 ? config.timeout : 5000;
  112. const startedAt = Date.now();
  113. // /health is the one endpoint sdcpp-restapi leaves unauthenticated, which
  114. // is what makes this usable as a reachability probe with no credential.
  115. let response;
  116. try {
  117. response = smartbotic.http.request({
  118. method: 'GET',
  119. url: server + '/health',
  120. timeout: timeout
  121. });
  122. } catch (e) {
  123. // A refused connection or a DNS failure throws rather than returning a
  124. // status. That is the single most useful thing a health check can
  125. // report, so it must not escape as a node error unless asked for.
  126. const result = unhealthy(server, 'Could not reach ' + server + ': ' + (e.message || e),
  127. Date.now() - startedAt);
  128. if (config.failIfUnreachable) {
  129. throw new Error('SD.cpp health: ' + result.error);
  130. }
  131. smartbotic.log.warn('SD.cpp health: ' + result.error);
  132. return result;
  133. }
  134. const responseMs = Date.now() - startedAt;
  135. if (!response || response.status < 200 || response.status >= 300) {
  136. const status = response ? response.status : 0;
  137. const result = unhealthy(server, 'Server answered with HTTP ' + status, responseMs);
  138. // It answered, so it is reachable - just not well. Keeping those apart
  139. // matters: a 500 is a broken server, a refused connection is a missing
  140. // one, and they call for different responses.
  141. result.reachable = status > 0;
  142. if (config.failIfUnreachable) {
  143. throw new Error('SD.cpp health: ' + result.error);
  144. }
  145. smartbotic.log.warn('SD.cpp health: ' + result.error);
  146. return result;
  147. }
  148. let health = response.data;
  149. if (typeof health === 'string') {
  150. try {
  151. health = JSON.parse(health);
  152. } catch (e) {
  153. const result = unhealthy(server, 'The server answered with something that is not JSON',
  154. responseMs);
  155. result.reachable = true;
  156. if (config.failIfUnreachable) {
  157. throw new Error('SD.cpp health: ' + result.error);
  158. }
  159. return result;
  160. }
  161. }
  162. health = health || {};
  163. const loadingStep = health.loading_step;
  164. const loadingTotal = health.loading_total_steps;
  165. const result = {
  166. healthy: true,
  167. reachable: true,
  168. status: health.status || '',
  169. error: '',
  170. responseMs: responseMs,
  171. modelLoaded: health.model_loaded === true,
  172. modelName: health.model_name || '',
  173. modelType: health.model_type || '',
  174. architecture: health.model_architecture || '',
  175. modelLoading: health.model_loading === true,
  176. loadingModelName: health.loading_model_name || '',
  177. loadingProgress: (loadingTotal > 0 && loadingStep !== undefined)
  178. ? Math.round((loadingStep / loadingTotal) * 100)
  179. : -1,
  180. upscalerLoaded: health.upscaler_loaded === true,
  181. upscalerName: health.upscaler_name || '',
  182. loadedComponents: health.loaded_components || {},
  183. // What the model was loaded WITH, not just which model it is. A node
  184. // that has to guarantee particular settings needs this to tell whether
  185. // the right model is loaded the right way.
  186. loadOptions: health.load_options || {},
  187. memory: health.memory || {},
  188. features: health.features || {},
  189. version: health.version || '',
  190. gitCommit: health.git_commit || '',
  191. lastError: health.last_error ? String(health.last_error) : ''
  192. };
  193. // A server that answers but has nothing loaded will reject a generation
  194. // job. Without these checks a health node would go green immediately before
  195. // the work it was guarding fails.
  196. const problems = [];
  197. if (config.requireModelLoaded && !result.modelLoaded) {
  198. problems.push(result.modelLoading
  199. ? 'a model is still loading (' + result.loadingModelName + ')'
  200. : 'no model is loaded');
  201. }
  202. if (config.requireUpscalerLoaded && !result.upscalerLoaded) {
  203. problems.push('no upscaler is loaded');
  204. }
  205. if (problems.length > 0) {
  206. result.healthy = false;
  207. result.error = 'Server is reachable but ' + problems.join(' and ');
  208. if (config.failIfUnreachable) {
  209. throw new Error('SD.cpp health: ' + result.error);
  210. }
  211. smartbotic.log.warn('SD.cpp health: ' + result.error);
  212. return result;
  213. }
  214. smartbotic.log.info('SD.cpp health: ' + server + ' healthy in ' + responseMs + 'ms' +
  215. (result.modelLoaded ? ', model ' + result.modelName + ' (' + result.architecture + ')'
  216. : ', no model loaded'));
  217. return result;
  218. }
  219. module.exports = { configSchema, inputSchema, outputSchema, execute };