perplexity-chat.js 20 KB

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