deepinfra-chat.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. /**
  2. * @node deepinfra-chat
  3. * @name DeepInfra Chat
  4. * @category ai
  5. * @version 1.0.0
  6. * @description Ask DeepInfra a question, with an image if there is one, and optionally get JSON back
  7. * @icon message-square
  8. */
  9. // Generated by scripts/gen-openai-compatible-nodes.py from one implementation
  10. // shared with every other OpenAI-compatible provider. Edit the generator, not
  11. // this file.
  12. // The credential this node wants, named so it can be found and so a key for
  13. // this provider is not offered to a different one. It is stored as a plain
  14. // bearer credential - that is what decides how it is encrypted - and this only
  15. // says which bearer credential is the DeepInfra one.
  16. const credentialTypes = [
  17. {
  18. id: 'deepinfra',
  19. label: 'DeepInfra API key',
  20. baseType: 'bearer',
  21. description: 'An API key from deepinfra.com/dash/api_keys',
  22. tokenLabel: 'API key'
  23. }
  24. ];
  25. const configSchema = {
  26. type: 'object',
  27. uiGroups: [
  28. { title: 'Connection', fields: ['baseUrl', 'credentialId'] },
  29. { title: 'Model', fields: ['model', 'systemPrompt', 'userPrompt'] },
  30. { title: 'Answer', fields: ['responseFormat', 'jsonMode', 'temperature', 'maxTokens', 'topP', 'seed'] },
  31. { title: 'Image', fields: ['imageMode', 'imageField', 'imageDetail', 'passthroughImage'] },
  32. { title: 'When it goes wrong', fields: ['retryCount', 'retryDelayMs', 'retryMaxDelayMs', 'skipOnError', 'timeoutMs'] },
  33. { title: 'Advanced', fields: ['extraHeaders', 'extraBody'] }
  34. ],
  35. properties: {
  36. baseUrl: {
  37. type: 'string', title: 'Base URL',
  38. description: 'Where the OpenAI-compatible API lives. The default is DeepInfra; change it to reach a proxy or a self-hosted gateway',
  39. default: 'https://api.deepinfra.com/v1/openai'
  40. },
  41. credentialId: {
  42. type: 'string', title: 'Credential',
  43. description: 'The stored DeepInfra API key',
  44. dynamicOptions: { source: 'credentials', filter: { type: ['deepinfra', 'bearer', 'api_key'] } }
  45. },
  46. model: {
  47. type: 'string', title: 'Model',
  48. description: 'Model name.',
  49. default: 'meta-llama/Llama-3.3-70B-Instruct',
  50. dynamicOptions: {
  51. source: 'node',
  52. config: { listOnly: true },
  53. itemsPath: 'models',
  54. valueKey: 'name',
  55. labelKey: 'name',
  56. needs: ['baseUrl', 'credentialId']
  57. }
  58. },
  59. systemPrompt: {
  60. type: 'string', title: 'System Prompt', format: 'textarea',
  61. description: 'The role and the output contract. When Response Format is JSON, say the word JSON here - several providers refuse JSON mode without it'
  62. },
  63. userPrompt: {
  64. type: 'string', title: 'User Prompt', format: 'textarea',
  65. description: 'The message sent with the request. Supports {{variable}} interpolation',
  66. default: 'Describe the input.'
  67. },
  68. responseFormat: {
  69. type: 'string', title: 'Response Format',
  70. enum: ['text', 'json'],
  71. enumLabels: ['Plain text', 'JSON object'],
  72. default: 'text',
  73. description: 'JSON parses the reply and fails the attempt when it is not valid JSON, so a retry gets another go'
  74. },
  75. jsonMode: {
  76. type: 'boolean', title: 'Ask The Provider For JSON', default: true,
  77. showWhen: { field: 'responseFormat', value: 'json' },
  78. description: 'Send response_format json_object, which makes the model return JSON rather than being asked nicely. Turn it off for a provider or model that rejects the field'
  79. },
  80. temperature: {
  81. type: 'number', title: 'Temperature', default: 0,
  82. description: 'Higher is more varied. 0 for the most repeatable output'
  83. },
  84. maxTokens: {
  85. type: 'number', title: 'Max Tokens',
  86. description: 'Upper bound on the reply. Leave empty for the provider default'
  87. },
  88. topP: {
  89. type: 'number', title: 'Top P',
  90. description: 'Nucleus sampling. Leave empty for the provider default'
  91. },
  92. seed: {
  93. type: 'number', title: 'Seed',
  94. description: 'Same seed and same input gives the same answer, where the provider supports it. Leave empty for none'
  95. },
  96. imageMode: {
  97. type: 'string', title: 'Image Input',
  98. enum: ['auto', 'none'],
  99. enumLabels: ['Send an image when the input has one', 'Text only'],
  100. default: 'none',
  101. description: 'Auto picks up a binary file, a base64 string or an image URL from the input. Needs a model that can see'
  102. },
  103. imageField: {
  104. valueKind: 'path',
  105. type: 'string', title: 'Image Field',
  106. showWhen: { field: 'imageMode', value: 'auto' },
  107. description: 'Optional dotted path to the base64 image, such as data.file.data. Left empty, the input is searched for one'
  108. },
  109. imageDetail: {
  110. type: 'string', title: 'Image Detail',
  111. enum: ['', 'low', 'high', 'auto'],
  112. enumLabels: ['(provider default)', 'Low - cheaper, coarser', 'High - more tokens, more detail', 'Auto'],
  113. default: '',
  114. showWhen: { field: 'imageMode', value: 'auto' },
  115. description: 'How closely to look at the image, where the provider supports it'
  116. },
  117. passthroughImage: {
  118. type: 'boolean', title: 'Pass The Image Through', default: false,
  119. showWhen: { field: 'imageMode', value: 'auto' },
  120. description: 'Include the base64 image in the output so a later node can store or reuse it'
  121. },
  122. retryCount: {
  123. type: 'number', title: 'Retries', default: 0,
  124. description: 'Extra attempts after a failed call or an unparseable answer'
  125. },
  126. retryDelayMs: {
  127. type: 'number', title: 'Retry Delay (ms)', default: 2000,
  128. description: 'Wait before the first retry. Doubles on each further attempt'
  129. },
  130. retryMaxDelayMs: {
  131. type: 'number', title: 'Max Retry Delay (ms)', default: 30000,
  132. description: 'Upper bound for the backoff'
  133. },
  134. skipOnError: {
  135. type: 'boolean', title: 'Skip On Error', default: false,
  136. description: 'Return success false instead of failing the workflow. Useful inside a loop, where one bad item should not end the run'
  137. },
  138. timeoutMs: {
  139. type: 'number', title: 'Timeout (ms)', default: 120000,
  140. description: 'Per-attempt request timeout'
  141. },
  142. extraHeaders: {
  143. type: 'object', title: 'Extra Headers',
  144. additionalProperties: { type: 'string' },
  145. description: 'Sent with the request. OpenRouter reads HTTP-Referer and X-Title to attribute usage'
  146. },
  147. extraBody: {
  148. type: 'object', title: 'Extra Body Fields',
  149. description: 'Merged into the request body, for anything this provider accepts that has no setting here'
  150. }
  151. },
  152. required: ['credentialId', 'model']
  153. };
  154. const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
  155. const outputSchema = {
  156. type: 'object',
  157. properties: {
  158. success: { type: 'boolean', description: 'False when the call failed and Skip On Error is on' },
  159. error: { type: 'string', description: 'Why it failed, when it did' },
  160. content: { type: 'string', description: 'The reply as text' },
  161. json: { type: 'any', description: 'The parsed reply, when Response Format is JSON' },
  162. model: { type: 'string', description: 'The model that answered, as the provider reported it' },
  163. finishReason: { type: 'string', description: 'Why the model stopped - stop, length, content_filter' },
  164. attempts: { type: 'number', description: 'How many attempts were made' },
  165. usage: { type: 'object', description: 'Token counts, when the provider reports them' },
  166. hadImage: { type: 'boolean' },
  167. imageBase64: { type: 'string', description: 'The image sent, when Pass The Image Through is on' },
  168. mimeType: { type: 'string' },
  169. sourceUrl: { type: 'string', description: 'Where the image came from, when it came from a URL' },
  170. // Only present when the node is asked for its model list rather than run.
  171. models: { type: 'array', description: 'The models this provider offers' }
  172. }
  173. };
  174. const PROVIDER = 'DeepInfra';
  175. function baseOf(config) {
  176. const value = String(config.baseUrl || 'https://api.deepinfra.com/v1/openai').trim().replace(/\/+$/, '');
  177. if (!value) {
  178. throw new Error(PROVIDER + ': a base URL is required, such as https://api.deepinfra.com/v1/openai');
  179. }
  180. return value;
  181. }
  182. function authHeaders(config) {
  183. const headers = { 'Content-Type': 'application/json' };
  184. const auth = smartbotic.credentials.get(config.credentialId);
  185. if (!auth || auth.success !== true) {
  186. throw new Error(PROVIDER + ': could not read the credential: ' +
  187. ((auth && auth.error) || 'unknown error'));
  188. }
  189. // Whatever shape the credential is stored as, it arrives as a ready-made
  190. // header - bearer and api_key both work without this node knowing which.
  191. headers[auth.headerName] = auth.headerValue;
  192. const extra = config.extraHeaders;
  193. if (extra && typeof extra === 'object') {
  194. const keys = Object.keys(extra);
  195. for (let i = 0; i < keys.length; i++) {
  196. if (extra[keys[i]] !== undefined && extra[keys[i]] !== null && extra[keys[i]] !== '') {
  197. headers[keys[i]] = String(extra[keys[i]]);
  198. }
  199. }
  200. }
  201. return headers;
  202. }
  203. function request(options) {
  204. const response = smartbotic.http.request(options);
  205. let body = response.data;
  206. if (typeof body === 'string' && body.length > 0) {
  207. try {
  208. body = JSON.parse(body);
  209. } catch (e) {
  210. if (response.status >= 200 && response.status < 300) {
  211. throw new Error(PROVIDER + ': the reply was not JSON: ' +
  212. body.substring(0, 200).replace(/\s+/g, ' '));
  213. }
  214. }
  215. }
  216. if (response.status < 200 || response.status >= 300) {
  217. let detail = 'HTTP ' + response.status;
  218. if (body && body.error) {
  219. detail = typeof body.error === 'string' ? body.error :
  220. (body.error.message || JSON.stringify(body.error));
  221. } else if (typeof body === 'string' && body) {
  222. detail = body.substring(0, 200).replace(/\s+/g, ' ');
  223. }
  224. throw new Error(PROVIDER + ' ' + options.what + ' failed: ' + detail);
  225. }
  226. return body || {};
  227. }
  228. function getPath(root, path) {
  229. if (!root || !path) {
  230. return undefined;
  231. }
  232. const parts = String(path).split('.');
  233. let current = root;
  234. for (let i = 0; i < parts.length; i++) {
  235. if (current === null || typeof current !== 'object') {
  236. return undefined;
  237. }
  238. current = current[parts[i]];
  239. }
  240. return current;
  241. }
  242. // Models are asked for JSON and answer with a fenced code block often enough
  243. // that refusing it would mean retrying a perfectly good answer.
  244. function stripFences(text) {
  245. const out = String(text || '').trim();
  246. if (out.indexOf('```') === -1) {
  247. return out;
  248. }
  249. const first = out.indexOf('{');
  250. const last = out.lastIndexOf('}');
  251. if (first !== -1 && last !== -1 && last > first) {
  252. return out.substring(first, last + 1);
  253. }
  254. const firstArr = out.indexOf('[');
  255. const lastArr = out.lastIndexOf(']');
  256. if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {
  257. return out.substring(firstArr, lastArr + 1);
  258. }
  259. return out;
  260. }
  261. // The image can be anywhere in the input: inside a loop, or with another node
  262. // between the download and this one, it is nested rather than sitting at the
  263. // top. So the input is searched breadth-first rather than guessed at.
  264. function findImage(input, override) {
  265. const result = { base64: '', mimeType: '', url: '' };
  266. if (override) {
  267. const direct = getPath(input, override);
  268. if (typeof direct === 'string' && direct.length > 0) {
  269. result.base64 = direct;
  270. return result;
  271. }
  272. }
  273. const roots = [];
  274. const queue = [input];
  275. let guard = 0;
  276. while (queue.length > 0 && guard < 64) {
  277. guard++;
  278. const node = queue.shift();
  279. if (!node || typeof node !== 'object') {
  280. continue;
  281. }
  282. roots.push(node);
  283. const keys = Object.keys(node);
  284. for (let k = 0; k < keys.length; k++) {
  285. const child = node[keys[k]];
  286. if (child && typeof child === 'object' && keys[k] !== 'file') {
  287. queue.push(child);
  288. }
  289. }
  290. }
  291. for (let i = 0; i < roots.length; i++) {
  292. const root = roots[i];
  293. if (!root || typeof root !== 'object') {
  294. continue;
  295. }
  296. if (!result.base64 && root.file && typeof root.file.data === 'string') {
  297. result.base64 = root.file.data;
  298. result.mimeType = root.file.mimeType || '';
  299. }
  300. if (!result.base64 && typeof root.base64 === 'string') {
  301. result.base64 = root.base64;
  302. }
  303. if (!result.base64 && typeof root.imageBase64 === 'string') {
  304. result.base64 = root.imageBase64;
  305. }
  306. if (!result.url && typeof root.url === 'string') {
  307. result.url = root.url;
  308. }
  309. if (!result.url && typeof root.sourceUrl === 'string') {
  310. result.url = root.sourceUrl;
  311. }
  312. }
  313. return result;
  314. }
  315. function buildMessages(config, image) {
  316. const messages = [];
  317. const system = String(config.systemPrompt || '').trim();
  318. if (system) {
  319. messages.push({ role: 'system', content: system });
  320. }
  321. const text = String(config.userPrompt || 'Describe the input.');
  322. if (!image.base64) {
  323. messages.push({ role: 'user', content: text });
  324. return messages;
  325. }
  326. // With an image the content becomes a list of parts, which is how every
  327. // OpenAI-compatible provider takes one. The base64 goes in as a data URI.
  328. const imagePart = {
  329. type: 'image_url',
  330. image_url: { url: 'data:' + (image.mimeType || 'image/jpeg') + ';base64,' + image.base64 }
  331. };
  332. if (config.imageDetail) {
  333. imagePart.image_url.detail = String(config.imageDetail);
  334. }
  335. messages.push({ role: 'user', content: [{ type: 'text', text: text }, imagePart] });
  336. return messages;
  337. }
  338. function putIfSet(target, key, value) {
  339. if (value === undefined || value === null || value === '') {
  340. return;
  341. }
  342. target[key] = value;
  343. }
  344. function chat(config, image, headers) {
  345. const body = {
  346. model: config.model,
  347. messages: buildMessages(config, image),
  348. stream: false
  349. };
  350. // Temperature 0 is a real setting and must survive, so emptiness is the
  351. // test rather than truthiness.
  352. putIfSet(body, 'temperature', config.temperature);
  353. putIfSet(body, 'max_tokens', config.maxTokens);
  354. putIfSet(body, 'top_p', config.topP);
  355. putIfSet(body, 'seed', config.seed);
  356. if (config.responseFormat === 'json' && config.jsonMode !== false) {
  357. body.response_format = { type: 'json_object' };
  358. }
  359. const extra = config.extraBody;
  360. if (extra && typeof extra === 'object') {
  361. const keys = Object.keys(extra);
  362. for (let i = 0; i < keys.length; i++) {
  363. body[keys[i]] = extra[keys[i]];
  364. }
  365. }
  366. const answer = request({
  367. method: 'POST',
  368. url: baseOf(config) + '/chat/completions',
  369. headers: headers,
  370. body: JSON.stringify(body),
  371. timeout: Number(config.timeoutMs) || 120000,
  372. what: 'the chat request'
  373. });
  374. const choice = (answer.choices && answer.choices[0]) || {};
  375. const message = choice.message || {};
  376. let content = message.content;
  377. // Some providers answer with the content already split into parts.
  378. if (content && typeof content !== 'string' && typeof content.length === 'number') {
  379. let joined = '';
  380. for (let i = 0; i < content.length; i++) {
  381. const part = content[i];
  382. if (part && typeof part.text === 'string') { joined += part.text; }
  383. }
  384. content = joined;
  385. }
  386. if (!content) {
  387. // A refusal is a documented field of its own, and reporting "empty
  388. // response" for one sends the reader looking in the wrong place.
  389. if (message.refusal) {
  390. throw new Error(PROVIDER + ' declined to answer: ' + message.refusal);
  391. }
  392. throw new Error(PROVIDER + ' returned an empty reply' +
  393. (choice.finish_reason ? ' (finished: ' + choice.finish_reason + ')' : ''));
  394. }
  395. return {
  396. content: String(content),
  397. model: String(answer.model || config.model),
  398. finishReason: String(choice.finish_reason || ''),
  399. usage: answer.usage || {}
  400. };
  401. }
  402. async function execute(config, input, context) {
  403. // Asked for its model list by the editor rather than run. Answered before
  404. // anything else, because none of the generation settings apply.
  405. if (config.listOnly === true) {
  406. const listing = request({
  407. method: 'GET',
  408. url: baseOf(config) + '/models',
  409. headers: authHeaders(config),
  410. timeout: Number(config.timeoutMs) || 30000,
  411. what: 'listing the models'
  412. });
  413. const rows = listing.data || listing.models || [];
  414. const models = [];
  415. for (let i = 0; i < rows.length; i++) {
  416. const row = rows[i] || {};
  417. const name = row.id || row.name || String(row);
  418. if (name) { models.push({ name: String(name) }); }
  419. }
  420. // Alphabetical: providers return these in whatever order they please,
  421. // and a list of hundreds is unusable without one.
  422. models.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
  423. smartbotic.log.info(PROVIDER + ': ' + models.length + ' model(s) on offer');
  424. return { models: models };
  425. }
  426. let image = { base64: '', mimeType: '', url: '' };
  427. if (config.imageMode === 'auto') {
  428. image = findImage(input, config.imageField);
  429. if (!image.base64 && image.url) {
  430. smartbotic.log.info(PROVIDER + ': fetching the image from ' + image.url);
  431. const download = smartbotic.http.request({
  432. method: 'GET', url: image.url, timeout: 60000
  433. });
  434. if (download.status < 200 || download.status >= 300) {
  435. throw new Error(PROVIDER + ': could not fetch the image: HTTP ' + download.status);
  436. }
  437. image.base64 = typeof download.data === 'string'
  438. ? smartbotic.utils.base64Encode(download.data) : '';
  439. }
  440. }
  441. // Resolved once, outside the retry loop: a missing or broken credential is
  442. // not transient, and retrying it with backoff only wastes time.
  443. const headers = authHeaders(config);
  444. const wantJson = config.responseFormat === 'json';
  445. const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
  446. let answer = null;
  447. let parsed = null;
  448. let lastError = '';
  449. let used = 0;
  450. for (let attempt = 1; attempt <= attempts; attempt++) {
  451. used = attempt;
  452. try {
  453. answer = chat(config, image, headers);
  454. if (wantJson) {
  455. parsed = JSON.parse(stripFences(answer.content));
  456. }
  457. lastError = '';
  458. break;
  459. } catch (err) {
  460. lastError = err && err.message ? err.message : String(err);
  461. parsed = null;
  462. smartbotic.log.warn(PROVIDER + ': attempt ' + attempt + ' of ' + attempts +
  463. ' failed: ' + lastError);
  464. if (attempt < attempts) {
  465. const base = Number(config.retryDelayMs) || 2000;
  466. const cap = Number(config.retryMaxDelayMs) || 30000;
  467. let delay = base * Math.pow(2, attempt - 1);
  468. if (delay > cap) { delay = cap; }
  469. smartbotic.log.info(PROVIDER + ': waiting ' + delay + 'ms before the next attempt');
  470. smartbotic.utils.sleep(delay);
  471. }
  472. }
  473. }
  474. const passImage = config.passthroughImage === true;
  475. const common = {
  476. attempts: used,
  477. hadImage: image.base64 ? true : false,
  478. imageBase64: passImage ? image.base64 : '',
  479. mimeType: image.mimeType,
  480. sourceUrl: image.url
  481. };
  482. if (lastError) {
  483. if (config.skipOnError !== true) {
  484. throw new Error(PROVIDER + ' failed after ' + used + ' attempt(s): ' + lastError);
  485. }
  486. smartbotic.log.warn(PROVIDER + ': skipping after ' + used + ' attempt(s)');
  487. return Object.assign({
  488. success: false, error: lastError, content: (answer && answer.content) || '',
  489. json: null, model: config.model, finishReason: '', usage: {}
  490. }, common);
  491. }
  492. return Object.assign({
  493. success: true, error: '', content: answer.content, json: parsed,
  494. model: answer.model, finishReason: answer.finishReason, usage: answer.usage
  495. }, common);
  496. }
  497. module.exports = { credentialTypes, configSchema, inputSchema, outputSchema, execute };