perplexity-chat.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  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 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 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. // Statuses this provider documents as answers that will not change.
  168. //
  169. // 401 does double duty: an invalid key AND an account out of credits both return it, so a 401 here is not necessarily a wrong key.
  170. //
  171. // 429 is treated as transient unless a code below says otherwise - backing off
  172. // is exactly what a rate limit asks for. 5xx is transient unless listed.
  173. const PERMANENT_STATUSES = [400, 401, 403];
  174. const PERMANENT_ERROR_CODES = [];
  175. function baseOf(config) {
  176. const value = String(config.baseUrl || 'https://api.perplexity.ai').trim().replace(/\/+$/, '');
  177. if (!value) {
  178. throw new Error(PROVIDER + ': a base URL is required, such as https://api.perplexity.ai');
  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. let code = '';
  219. if (body && body.error) {
  220. detail = typeof body.error === 'string' ? body.error :
  221. (body.error.message || JSON.stringify(body.error));
  222. code = (body.error && body.error.code) || (body.error && body.error.type) || '';
  223. } else if (typeof body === 'string' && body) {
  224. detail = body.substring(0, 200).replace(/\s+/g, ' ');
  225. }
  226. const error = new Error(PROVIDER + ' ' + options.what + ' failed: ' + detail);
  227. error.status = response.status;
  228. // Whether asking again could ever give a different answer. Retrying a
  229. // refusal does not just waste time - where the refusal is a spent
  230. // allowance or a billing stop, it spends more of whatever ran out.
  231. error.permanent = PERMANENT_STATUSES.indexOf(response.status) !== -1 ||
  232. (PERMANENT_ERROR_CODES.length > 0 && code &&
  233. PERMANENT_ERROR_CODES.indexOf(String(code)) !== -1);
  234. throw error;
  235. }
  236. return body || {};
  237. }
  238. function getPath(root, path) {
  239. if (!root || !path) {
  240. return undefined;
  241. }
  242. const parts = String(path).split('.');
  243. let current = root;
  244. for (let i = 0; i < parts.length; i++) {
  245. if (current === null || typeof current !== 'object') {
  246. return undefined;
  247. }
  248. current = current[parts[i]];
  249. }
  250. return current;
  251. }
  252. // Models are asked for JSON and answer with a fenced code block often enough
  253. // that refusing it would mean retrying a perfectly good answer.
  254. function stripFences(text) {
  255. const out = String(text || '').trim();
  256. if (out.indexOf('```') === -1) {
  257. return out;
  258. }
  259. const first = out.indexOf('{');
  260. const last = out.lastIndexOf('}');
  261. if (first !== -1 && last !== -1 && last > first) {
  262. return out.substring(first, last + 1);
  263. }
  264. const firstArr = out.indexOf('[');
  265. const lastArr = out.lastIndexOf(']');
  266. if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {
  267. return out.substring(firstArr, lastArr + 1);
  268. }
  269. return out;
  270. }
  271. // The image can be anywhere in the input: inside a loop, or with another node
  272. // between the download and this one, it is nested rather than sitting at the
  273. // top. So the input is searched breadth-first rather than guessed at.
  274. function findImage(input, override) {
  275. const result = { base64: '', mimeType: '', url: '' };
  276. if (override) {
  277. const direct = getPath(input, override);
  278. if (typeof direct === 'string' && direct.length > 0) {
  279. result.base64 = direct;
  280. return result;
  281. }
  282. }
  283. const roots = [];
  284. const queue = [input];
  285. let guard = 0;
  286. while (queue.length > 0 && guard < 64) {
  287. guard++;
  288. const node = queue.shift();
  289. if (!node || typeof node !== 'object') {
  290. continue;
  291. }
  292. roots.push(node);
  293. const keys = Object.keys(node);
  294. for (let k = 0; k < keys.length; k++) {
  295. const child = node[keys[k]];
  296. if (child && typeof child === 'object' && keys[k] !== 'file') {
  297. queue.push(child);
  298. }
  299. }
  300. }
  301. for (let i = 0; i < roots.length; i++) {
  302. const root = roots[i];
  303. if (!root || typeof root !== 'object') {
  304. continue;
  305. }
  306. if (!result.base64 && root.file && typeof root.file.data === 'string') {
  307. result.base64 = root.file.data;
  308. result.mimeType = root.file.mimeType || '';
  309. }
  310. if (!result.base64 && typeof root.base64 === 'string') {
  311. result.base64 = root.base64;
  312. }
  313. if (!result.base64 && typeof root.imageBase64 === 'string') {
  314. result.base64 = root.imageBase64;
  315. }
  316. if (!result.url && typeof root.url === 'string') {
  317. result.url = root.url;
  318. }
  319. if (!result.url && typeof root.sourceUrl === 'string') {
  320. result.url = root.sourceUrl;
  321. }
  322. }
  323. return result;
  324. }
  325. function buildMessages(config, image) {
  326. const messages = [];
  327. const system = String(config.systemPrompt || '').trim();
  328. if (system) {
  329. messages.push({ role: 'system', content: system });
  330. }
  331. const text = String(config.userPrompt || 'Describe the input.');
  332. if (!image.base64) {
  333. messages.push({ role: 'user', content: text });
  334. return messages;
  335. }
  336. // With an image the content becomes a list of parts, which is how every
  337. // OpenAI-compatible provider takes one. The base64 goes in as a data URI.
  338. const imagePart = {
  339. type: 'image_url',
  340. image_url: { url: 'data:' + (image.mimeType || 'image/jpeg') + ';base64,' + image.base64 }
  341. };
  342. if (config.imageDetail) {
  343. imagePart.image_url.detail = String(config.imageDetail);
  344. }
  345. messages.push({ role: 'user', content: [{ type: 'text', text: text }, imagePart] });
  346. return messages;
  347. }
  348. function putIfSet(target, key, value) {
  349. if (value === undefined || value === null || value === '') {
  350. return;
  351. }
  352. target[key] = value;
  353. }
  354. function chat(config, image, headers) {
  355. const body = {
  356. model: config.model,
  357. messages: buildMessages(config, image),
  358. stream: false
  359. };
  360. // Temperature 0 is a real setting and must survive, so emptiness is the
  361. // test rather than truthiness.
  362. putIfSet(body, 'temperature', config.temperature);
  363. putIfSet(body, 'max_tokens', config.maxTokens);
  364. putIfSet(body, 'top_p', config.topP);
  365. putIfSet(body, 'seed', config.seed);
  366. if (config.responseFormat === 'json' && config.jsonMode !== false) {
  367. body.response_format = { type: 'json_object' };
  368. }
  369. const extra = config.extraBody;
  370. if (extra && typeof extra === 'object') {
  371. const keys = Object.keys(extra);
  372. for (let i = 0; i < keys.length; i++) {
  373. body[keys[i]] = extra[keys[i]];
  374. }
  375. }
  376. const answer = request({
  377. method: 'POST',
  378. url: baseOf(config) + '/chat/completions',
  379. headers: headers,
  380. body: JSON.stringify(body),
  381. timeout: Number(config.timeoutMs) || 120000,
  382. what: 'the chat request'
  383. });
  384. const choice = (answer.choices && answer.choices[0]) || {};
  385. const message = choice.message || {};
  386. let content = message.content;
  387. // Some providers answer with the content already split into parts.
  388. if (content && typeof content !== 'string' && typeof content.length === 'number') {
  389. let joined = '';
  390. for (let i = 0; i < content.length; i++) {
  391. const part = content[i];
  392. if (part && typeof part.text === 'string') { joined += part.text; }
  393. }
  394. content = joined;
  395. }
  396. if (!content) {
  397. // A refusal is a documented field of its own, and reporting "empty
  398. // response" for one sends the reader looking in the wrong place.
  399. if (message.refusal) {
  400. throw new Error(PROVIDER + ' declined to answer: ' + message.refusal);
  401. }
  402. throw new Error(PROVIDER + ' returned an empty reply' +
  403. (choice.finish_reason ? ' (finished: ' + choice.finish_reason + ')' : ''));
  404. }
  405. return {
  406. content: String(content),
  407. model: String(answer.model || config.model),
  408. finishReason: String(choice.finish_reason || ''),
  409. usage: answer.usage || {}
  410. };
  411. }
  412. async function execute(config, input, context) {
  413. // Asked for its model list by the editor rather than run. Answered before
  414. // anything else, because none of the generation settings apply.
  415. if (config.listOnly === true) {
  416. // This provider publishes no model list, so there is nothing to
  417. // offer and saying so beats an empty dropdown with no explanation.
  418. return { models: [] };
  419. }
  420. let image = { base64: '', mimeType: '', url: '' };
  421. if (config.imageMode === 'auto') {
  422. image = findImage(input, config.imageField);
  423. if (!image.base64 && image.url) {
  424. smartbotic.log.info(PROVIDER + ': fetching the image from ' + image.url);
  425. const download = smartbotic.http.request({
  426. method: 'GET', url: image.url, timeout: 60000
  427. });
  428. if (download.status < 200 || download.status >= 300) {
  429. throw new Error(PROVIDER + ': could not fetch the image: HTTP ' + download.status);
  430. }
  431. image.base64 = typeof download.data === 'string'
  432. ? smartbotic.utils.base64Encode(download.data) : '';
  433. }
  434. }
  435. // Resolved once, outside the retry loop: a missing or broken credential is
  436. // not transient, and retrying it with backoff only wastes time.
  437. const headers = authHeaders(config);
  438. const wantJson = config.responseFormat === 'json';
  439. const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
  440. let answer = null;
  441. let parsed = null;
  442. let lastError = '';
  443. let used = 0;
  444. for (let attempt = 1; attempt <= attempts; attempt++) {
  445. used = attempt;
  446. try {
  447. answer = chat(config, image, headers);
  448. if (wantJson) {
  449. parsed = JSON.parse(stripFences(answer.content));
  450. }
  451. lastError = '';
  452. break;
  453. } catch (err) {
  454. lastError = err && err.message ? err.message : String(err);
  455. parsed = null;
  456. smartbotic.log.warn(PROVIDER + ': attempt ' + attempt + ' of ' + attempts +
  457. ' failed: ' + lastError);
  458. if (err && err.permanent) {
  459. smartbotic.log.warn(PROVIDER + ': the request was refused (HTTP ' +
  460. err.status + '), so the remaining attempts were not made');
  461. break;
  462. }
  463. if (attempt < attempts) {
  464. const base = Number(config.retryDelayMs) || 2000;
  465. const cap = Number(config.retryMaxDelayMs) || 30000;
  466. let delay = base * Math.pow(2, attempt - 1);
  467. if (delay > cap) { delay = cap; }
  468. smartbotic.log.info(PROVIDER + ': waiting ' + delay + 'ms before the next attempt');
  469. smartbotic.utils.sleep(delay);
  470. }
  471. }
  472. }
  473. const passImage = config.passthroughImage === true;
  474. const common = {
  475. attempts: used,
  476. hadImage: image.base64 ? true : false,
  477. imageBase64: passImage ? image.base64 : '',
  478. mimeType: image.mimeType,
  479. sourceUrl: image.url
  480. };
  481. if (lastError) {
  482. if (config.skipOnError !== true) {
  483. throw new Error(PROVIDER + ' failed after ' + used + ' attempt(s): ' + lastError);
  484. }
  485. smartbotic.log.warn(PROVIDER + ': skipping after ' + used + ' attempt(s)');
  486. return Object.assign({
  487. success: false, error: lastError, content: (answer && answer.content) || '',
  488. json: null, model: config.model, finishReason: '', usage: {}
  489. }, common);
  490. }
  491. return Object.assign({
  492. success: true, error: '', content: answer.content, json: parsed,
  493. model: answer.model, finishReason: answer.finishReason, usage: answer.usage
  494. }, common);
  495. }
  496. module.exports = { credentialTypes, configSchema, inputSchema, outputSchema, execute };