From 6d3e6eef00ed3acf35d376b88ce6586692ae641b Mon Sep 17 00:00:00 2001 From: Stanimira Rikova <104592468+Stamsy@users.noreply.github.com> Date: Thu, 29 May 2025 22:01:19 +0300 Subject: [PATCH] feat(Perplexity Node): New node (#13604) --- .../credentials/PerplexityApi.credentials.ts | 52 +++ .../nodes/Perplexity/GenericFunctions.ts | 41 +++ .../nodes/Perplexity/Perplexity.node.json | 18 + .../nodes/Perplexity/Perplexity.node.ts | 51 +++ .../descriptions/chat/Chat.resource.ts | 38 +++ .../descriptions/chat/complete.operation.ts | 318 ++++++++++++++++++ .../nodes/Perplexity/descriptions/index.ts | 1 + .../nodes/Perplexity/perplexity.svg | 35 ++ .../Perplexity/test/GenericFunction.test.ts | 205 +++++++++++ .../Perplexity/test/Perplexity.node.test.ts | 22 ++ .../Perplexity/test/chat/complete.test.ts | 55 +++ .../test/chat/complete.workflow.json | 98 ++++++ .../test/credentials/PerplexityApi.test.ts | 52 +++ packages/nodes-base/package.json | 2 + 14 files changed, 988 insertions(+) create mode 100644 packages/nodes-base/credentials/PerplexityApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Perplexity/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Perplexity/Perplexity.node.json create mode 100644 packages/nodes-base/nodes/Perplexity/Perplexity.node.ts create mode 100644 packages/nodes-base/nodes/Perplexity/descriptions/chat/Chat.resource.ts create mode 100644 packages/nodes-base/nodes/Perplexity/descriptions/chat/complete.operation.ts create mode 100644 packages/nodes-base/nodes/Perplexity/descriptions/index.ts create mode 100644 packages/nodes-base/nodes/Perplexity/perplexity.svg create mode 100644 packages/nodes-base/nodes/Perplexity/test/GenericFunction.test.ts create mode 100644 packages/nodes-base/nodes/Perplexity/test/Perplexity.node.test.ts create mode 100644 packages/nodes-base/nodes/Perplexity/test/chat/complete.test.ts create mode 100644 packages/nodes-base/nodes/Perplexity/test/chat/complete.workflow.json create mode 100644 packages/nodes-base/nodes/Perplexity/test/credentials/PerplexityApi.test.ts diff --git a/packages/nodes-base/credentials/PerplexityApi.credentials.ts b/packages/nodes-base/credentials/PerplexityApi.credentials.ts new file mode 100644 index 0000000000..4522f4c650 --- /dev/null +++ b/packages/nodes-base/credentials/PerplexityApi.credentials.ts @@ -0,0 +1,52 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class PerplexityApi implements ICredentialType { + name = 'perplexityApi'; + + displayName = 'Perplexity API'; + + documentationUrl = 'https://docs.perplexity.ai'; + + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + required: true, + default: '', + description: 'Your Perplexity API key. Get it from your Perplexity account.', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://api.perplexity.ai', + url: '/chat/completions', + method: 'POST', + body: { + model: 'r1-1776', + messages: [{ role: 'user', content: 'test' }], + }, + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + 'Content-Type': 'application/json', + }, + json: true, + }, + }; +} diff --git a/packages/nodes-base/nodes/Perplexity/GenericFunctions.ts b/packages/nodes-base/nodes/Perplexity/GenericFunctions.ts new file mode 100644 index 0000000000..60014c877a --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/GenericFunctions.ts @@ -0,0 +1,41 @@ +import type { + IExecuteSingleFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +export async function sendErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + const errorBody = response.body as JsonObject; + const error = (errorBody?.error ?? {}) as JsonObject; + + const errorMessage = + typeof error.message === 'string' + ? error.message + : (response.statusMessage ?? 'An unexpected issue occurred'); + const errorType = typeof error.type === 'string' ? error.type : 'UnknownError'; + const itemIndex = typeof error.itemIndex === 'number' ? `[Item ${error.itemIndex}]` : ''; + + if (errorType === 'invalid_model') { + throw new NodeApiError(this.getNode(), errorBody, { + message: 'Invalid model', + description: + 'The model is not valid. Permitted models can be found in the documentation at https://docs.perplexity.ai/guides/model-cards.', + }); + } + + // Fallback for other errors + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + message: `${errorMessage}${itemIndex ? ' ' + itemIndex : ''}.`, + description: + 'Any optional system messages must be sent first, followed by alternating user and assistant messages. For more details, refer to the API documentation: https://docs.perplexity.ai/api-reference/chat-completions', + }); + } + return data; +} diff --git a/packages/nodes-base/nodes/Perplexity/Perplexity.node.json b/packages/nodes-base/nodes/Perplexity/Perplexity.node.json new file mode 100644 index 0000000000..59b0de7d65 --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/Perplexity.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.perplexity", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Utility"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/perplexity/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.perplexity/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Perplexity/Perplexity.node.ts b/packages/nodes-base/nodes/Perplexity/Perplexity.node.ts new file mode 100644 index 0000000000..52adb6441f --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/Perplexity.node.ts @@ -0,0 +1,51 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { chat } from './descriptions'; + +export class Perplexity implements INodeType { + description: INodeTypeDescription = { + displayName: 'Perplexity', + name: 'perplexity', + icon: { + light: 'file:perplexity.svg', + dark: 'file:perplexity.dark.svg', + }, + group: ['transform'], + version: 1, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', + description: 'Interact with the Perplexity API to generate AI responses with citations', + defaults: { + name: 'Perplexity', + }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + usableAsTool: true, + credentials: [ + { + name: 'perplexityApi', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://api.perplexity.ai', + ignoreHttpStatusErrors: true, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'hidden', + noDataExpression: true, + options: [ + { + name: 'Chat', + value: 'chat', + }, + ], + default: 'chat', + }, + ...chat.description, + ], + }; +} diff --git a/packages/nodes-base/nodes/Perplexity/descriptions/chat/Chat.resource.ts b/packages/nodes-base/nodes/Perplexity/descriptions/chat/Chat.resource.ts new file mode 100644 index 0000000000..f986ca9f43 --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/descriptions/chat/Chat.resource.ts @@ -0,0 +1,38 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as complete from './complete.operation'; +import { sendErrorPostReceive } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['chat'], + }, + }, + options: [ + { + name: 'Message a Model', + value: 'complete', + action: 'Message a model', + description: 'Create one or more completions for a given text', + routing: { + request: { + method: 'POST', + url: '/chat/completions', + }, + output: { + postReceive: [sendErrorPostReceive], + }, + }, + }, + ], + default: 'complete', + }, + + ...complete.description, +]; diff --git a/packages/nodes-base/nodes/Perplexity/descriptions/chat/complete.operation.ts b/packages/nodes-base/nodes/Perplexity/descriptions/chat/complete.operation.ts new file mode 100644 index 0000000000..ad536331c0 --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/descriptions/chat/complete.operation.ts @@ -0,0 +1,318 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +const properties: INodeProperties[] = [ + { + displayName: 'Model', + name: 'model', + type: 'options', + default: 'r1-1776', + required: true, + options: [ + { name: 'R1-1776', value: 'r1-1776' }, + { name: 'Sonar', value: 'sonar' }, + { name: 'Sonar Deep Research', value: 'sonar-deep-research' }, + { name: 'Sonar Pro', value: 'sonar-pro' }, + { name: 'Sonar Reasoning', value: 'sonar-reasoning' }, + { name: 'Sonar Reasoning Pro', value: 'sonar-reasoning-pro' }, + ], + description: 'The model which will generate the completion', + routing: { + send: { + type: 'body', + property: 'model', + }, + }, + }, + { + displayName: 'Messages', + name: 'messages', + type: 'fixedCollection', + description: + 'Any optional system messages must be sent first, followed by alternating user and assistant messages', + required: true, + typeOptions: { + multipleValues: true, + sortable: true, + }, + placeholder: 'Add Message', + default: { + message: [ + { + role: 'user', + content: '', + }, + ], + }, + options: [ + { + displayName: 'Message', + name: 'message', + values: [ + { + displayName: 'Text', + name: 'content', + type: 'string', + default: '', + description: 'The content of the message to be sent', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Role', + name: 'role', + required: true, + type: 'options', + options: [ + { + name: 'Assistant', + value: 'assistant', + description: + 'Tell the model to adopt a specific tone or personality. Must alternate with user messages.', + }, + { + name: 'System', + value: 'system', + description: + 'Set the models behavior or context. Must come before user and assistant messages.', + }, + { + name: 'User', + value: 'user', + description: 'Send a message as a user and get a response from the model', + }, + ], + default: 'user', + description: + "Role in shaping the model's response, it tells the model how it should behave and interact with the user", + }, + ], + }, + ], + routing: { + send: { + type: 'body', + property: 'messages', + value: '={{ $value.message }}', + }, + }, + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: false, + description: 'Whether to return only essential fields (ID, citations, message)', + routing: { + output: { + postReceive: [ + { + type: 'set', + enabled: '={{ $value }}', + properties: { + value: + '={{ { "id": $response.body?.id, "created": $response.body?.created, "citations": $response.body?.citations, "message": $response.body?.choices?.[0]?.message?.content } }}', + }, + }, + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Frequency Penalty', + name: 'frequencyPenalty', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + description: + "Values greater than 1.0 penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim", + routing: { + send: { + type: 'body', + property: 'frequency_penalty', + }, + }, + }, + { + displayName: 'Maximum Number of Tokens', + name: 'maxTokens', + type: 'number', + default: 1, + description: + 'The maximum number of tokens to generate in the completion. The number of tokens requested plus the number of prompt tokens sent in messages must not exceed the context window token limit of model requested.', + routing: { + send: { + type: 'body', + property: 'max_tokens', + }, + }, + }, + { + displayName: 'Output Randomness (Temperature)', + name: 'temperature', + type: 'number', + default: 0.2, + description: + 'The amount of randomness in the response, valued between 0 inclusive and 2 exclusive. Higher values are more random, and lower values are more deterministic.', + typeOptions: { + minValue: 0, + maxValue: 1.99, + }, + routing: { + send: { + type: 'body', + property: 'temperature', + }, + }, + }, + { + displayName: 'Top K', + name: 'topK', + type: 'number', + default: 0, + description: + 'The number of tokens to keep for highest Top K filtering, specified as an integer between 0 and 2048 inclusive. If set to 0, Top K filtering is disabled. We recommend either altering Top K or Top P, but not both.', + typeOptions: { + minValue: 0, + maxValue: 2048, + }, + routing: { + send: { + type: 'body', + property: 'top_k', + }, + }, + }, + { + displayName: 'Top P', + name: 'topP', + type: 'number', + default: 0.9, + description: + 'The nucleus sampling threshold, valued between 0 and 1 inclusive. For each subsequent token, the model considers the results of the tokens with Top P probability mass. We recommend either altering Top K or Top P, but not both.', + typeOptions: { + minValue: 0, + maxValue: 1, + }, + routing: { + send: { + type: 'body', + property: 'top_p', + }, + }, + }, + { + displayName: 'Presence Penalty', + name: 'presencePenalty', + type: 'number', + default: 0, + description: + "A value between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.", + typeOptions: { + minValue: -2.0, + maxValue: 2.0, + }, + routing: { + send: { + type: 'body', + property: 'presence_penalty', + }, + }, + }, + { + displayName: 'Return Images', + name: 'returnImages', + type: 'boolean', + default: false, + description: + 'Whether or not a request to an online model should return images. Requires Perplexity API usage Tier-2.', + routing: { + send: { + type: 'body', + property: 'return_images', + }, + }, + }, + { + displayName: 'Return Related Questions', + name: 'returnRelatedQuestions', + type: 'boolean', + default: false, + description: + 'Whether or not a request to an online model should return related questions. Requires Perplexity API usage Tier-2.', + routing: { + send: { + type: 'body', + property: 'return_related_questions', + }, + }, + }, + { + displayName: 'Search Domain Filter', + name: 'searchDomainFilter', + type: 'string', + default: '', + description: + 'Limit the citations used by the online model to URLs from the specified domains. For blacklisting, add a - to the beginning of the domain string (e.g., -domain1). Currently limited to 3 domains. Requires Perplexity API usage Tier-3.', + placeholder: 'e.g. domain1,domain2,-domain3', + routing: { + send: { + type: 'body', + property: 'search_domain_filter', + value: '={{ $value.split(",").map(domain => domain.trim()) }}', + }, + }, + }, + { + displayName: 'Search Recency Filter', + name: 'searchRecency', + type: 'options', + options: [ + { + name: 'Day', + value: 'day', + }, + { + name: 'Hour', + value: 'hour', + }, + { + name: 'Month', + value: 'month', + }, + { + name: 'Week', + value: 'week', + }, + ], + default: 'month', + description: 'Returns search results within the specified time interval', + routing: { + send: { + type: 'body', + property: 'search_recency', + }, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['chat'], + operation: ['complete'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Perplexity/descriptions/index.ts b/packages/nodes-base/nodes/Perplexity/descriptions/index.ts new file mode 100644 index 0000000000..17087df57e --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/descriptions/index.ts @@ -0,0 +1 @@ +export * as chat from './chat/Chat.resource'; diff --git a/packages/nodes-base/nodes/Perplexity/perplexity.svg b/packages/nodes-base/nodes/Perplexity/perplexity.svg new file mode 100644 index 0000000000..4789aabaca --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/perplexity.svg @@ -0,0 +1,35 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/Perplexity/test/GenericFunction.test.ts b/packages/nodes-base/nodes/Perplexity/test/GenericFunction.test.ts new file mode 100644 index 0000000000..a79cee1cf9 --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/test/GenericFunction.test.ts @@ -0,0 +1,205 @@ +import type { + IExecuteSingleFunctions, + IN8nHttpFullResponse, + INodeExecutionData, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { sendErrorPostReceive } from '../GenericFunctions'; + +// Mock implementation for `this` in `sendErrorPostReceive` +const mockExecuteSingleFunctions = { + getNode: () => ({ + name: 'Mock Node', + type: 'mock-type', + position: [0, 0], + }), +} as unknown as IExecuteSingleFunctions; + +describe('Generic Functions', () => { + describe('sendErrorPostReceive', () => { + let testData: INodeExecutionData[]; + let testResponse: IN8nHttpFullResponse; + + beforeEach(() => { + testData = [{ json: {} }]; + testResponse = { statusCode: 200, headers: {}, body: {} }; + }); + + it('should return data if status code is not 4xx or 5xx', async () => { + const result = await sendErrorPostReceive.call( + mockExecuteSingleFunctions, + testData, + testResponse, + ); + expect(result).toEqual(testData); + }); + + it('should throw NodeApiError if status code is 4xx', async () => { + testResponse.statusCode = 400; + await expect( + sendErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse), + ).rejects.toThrow(NodeApiError); + }); + + it('should throw NodeApiError if status code is 5xx', async () => { + testResponse.statusCode = 500; + await expect( + sendErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse), + ).rejects.toThrow(NodeApiError); + }); + + it('should throw NodeApiError with "Invalid model" message if error type is invalid_model', async () => { + const errorResponse = { + statusCode: 400, + body: { + error: { + type: 'invalid_model', + message: 'Invalid model type provided', + }, + }, + }; + + await expect( + sendErrorPostReceive.call( + mockExecuteSingleFunctions, + testData, + errorResponse as unknown as IN8nHttpFullResponse, + ), + ).rejects.toThrowError( + new NodeApiError(mockExecuteSingleFunctions.getNode(), errorResponse.body, { + message: 'Invalid model', + description: + 'The model is not valid. Permitted models can be found in the documentation at https://docs.perplexity.ai/guides/model-cards.', + }), + ); + }); + + it('should throw NodeApiError with "Invalid parameter" message if error type is invalid_parameter', async () => { + const errorResponse = { + statusCode: 400, + body: { + error: { + type: 'invalid_parameter', + message: 'Invalid parameter provided', + }, + }, + }; + + await expect( + sendErrorPostReceive.call( + mockExecuteSingleFunctions, + testData, + errorResponse as unknown as IN8nHttpFullResponse, + ), + ).rejects.toThrowError( + new NodeApiError(mockExecuteSingleFunctions.getNode(), errorResponse.body, { + message: 'Invalid parameter provided.', + description: + 'Please check all input parameters and ensure they are correctly formatted. Valid values can be found in the documentation at https://docs.perplexity.ai/api-reference/chat-completions.', + }), + ); + }); + + it('should handle "invalid_model" error with itemIndex', async () => { + const errorResponse = { + statusCode: 400, + body: { + error: { + type: 'invalid_model', + message: 'Invalid model', + itemIndex: 0, + }, + }, + }; + + await expect( + sendErrorPostReceive.call( + mockExecuteSingleFunctions, + testData, + errorResponse as unknown as IN8nHttpFullResponse, + ), + ).rejects.toThrowError( + new NodeApiError(mockExecuteSingleFunctions.getNode(), errorResponse.body, { + message: 'Invalid model', + description: 'Permitted models documentation...', + }), + ); + }); + + it('should handle "invalid_parameter" error with non-string message', async () => { + const errorResponse = { + statusCode: 400, + body: { + error: { + type: 'invalid_parameter', + message: { detail: 'Invalid param' }, + }, + }, + }; + + await expect( + sendErrorPostReceive.call( + mockExecuteSingleFunctions, + testData, + errorResponse as unknown as IN8nHttpFullResponse, + ), + ).rejects.toThrowError( + new NodeApiError(mockExecuteSingleFunctions.getNode(), errorResponse.body, { + message: 'An unexpected issue occurred.', + description: 'Please check parameters...', + }), + ); + }); + + it('should throw generic error for unknown error type', async () => { + const errorResponse = { + statusCode: 500, + body: { + error: { + type: 'server_error', + message: 'Internal server error', + }, + }, + }; + + await expect( + sendErrorPostReceive.call( + mockExecuteSingleFunctions, + testData, + errorResponse as unknown as IN8nHttpFullResponse, + ), + ).rejects.toThrowError( + new NodeApiError(mockExecuteSingleFunctions.getNode(), errorResponse.body, { + message: 'Internal server error.', + description: 'Refer to API documentation...', + }), + ); + }); + + it('should include itemIndex in error message when present', async () => { + const errorResponse = { + statusCode: 400, + body: { + error: { + type: 'other_error', + message: 'Error with item', + itemIndex: 2, + }, + }, + }; + + await expect( + sendErrorPostReceive.call( + mockExecuteSingleFunctions, + testData, + errorResponse as unknown as IN8nHttpFullResponse, + ), + ).rejects.toThrowError( + new NodeApiError(mockExecuteSingleFunctions.getNode(), errorResponse.body, { + message: 'Error with item [Item 2].', + }), + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Perplexity/test/Perplexity.node.test.ts b/packages/nodes-base/nodes/Perplexity/test/Perplexity.node.test.ts new file mode 100644 index 0000000000..283b67f6e7 --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/test/Perplexity.node.test.ts @@ -0,0 +1,22 @@ +import { Perplexity } from '../../Perplexity/Perplexity.node'; +import { description } from '../descriptions/chat/complete.operation'; + +jest.mock('../../Perplexity/GenericFunctions', () => ({ + getModels: jest.fn(), +})); + +describe('Perplexity Node', () => { + let node: Perplexity; + + beforeEach(() => { + node = new Perplexity(); + }); + + describe('Node Description', () => { + it('should correctly include chat completion properties', () => { + const properties = node.description.properties; + + expect(properties).toEqual(expect.arrayContaining(description)); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Perplexity/test/chat/complete.test.ts b/packages/nodes-base/nodes/Perplexity/test/chat/complete.test.ts new file mode 100644 index 0000000000..83d7a59a5a --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/test/chat/complete.test.ts @@ -0,0 +1,55 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +const credentials = { + perplexityApi: { + apiKey: 'test-api-key', + baseUrl: 'https://api.perplexity.ai', + }, +}; + +describe('Perplexity Node - Chat Completions', () => { + beforeEach(() => { + nock.disableNetConnect(); + nock('https://api.perplexity.ai') + .post('/chat/completions', (body) => { + return ( + body?.model?.value === 'r1-1776' && + body?.model?.mode === 'id' && + Array.isArray(body?.messages) && + body.messages.length === 3 && + body.messages[0].role === 'user' && + body.messages[1].role === 'assistant' && + body.messages[2].role === 'user' + ); + }) + .reply(200, { + id: '6bb24c98-3071-4691-9a7b-dc4bc18c3c2c', + model: 'r1-1776', + created: 1743161086, + object: 'chat.completion', + usage: { + prompt_tokens: 4, + completion_tokens: 4, + total_tokens: 8, + }, + choices: [ + { + index: 0, + finish_reason: 'length', + message: { + role: 'assistant', + content: '\nOkay,', + }, + }, + ], + }); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + new NodeTestHarness().setupTests({ credentials }); +}); diff --git a/packages/nodes-base/nodes/Perplexity/test/chat/complete.workflow.json b/packages/nodes-base/nodes/Perplexity/test/chat/complete.workflow.json new file mode 100644 index 0000000000..26e579a476 --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/test/chat/complete.workflow.json @@ -0,0 +1,98 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-80, -680], + "id": "a9105e3d-172b-411e-8c06-767a3ce1003a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "model": { + "__rl": true, + "value": "r1-1776", + "mode": "id" + }, + "messages": { + "message": [ + { + "content": "test" + }, + { + "content": "test", + "role": "assistant" + }, + { + "content": "aaa" + } + ] + }, + "options": { + "frequencyPenalty": 1, + "maxTokens": 4, + "temperature": 1.99, + "topK": 4, + "topP": 1, + "presencePenalty": 2, + "returnImages": false, + "returnRelatedQuestions": false, + "searchRecency": "month" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.perplexity", + "typeVersion": 1, + "position": [-40, -380], + "id": "8e8f857d-d773-449b-82bf-d96aabdb8c9f", + "name": "Role Assistant and User", + "credentials": { + "perplexityApi": { + "id": "zjtHjl6uQKo1V2rm", + "name": "Perplexity account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Role Assistant and User", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "Role Assistant and User": [ + { + "json": { + "id": "6bb24c98-3071-4691-9a7b-dc4bc18c3c2c", + "model": "r1-1776", + "created": 1743161086, + "usage": { + "prompt_tokens": 4, + "completion_tokens": 4, + "total_tokens": 8 + }, + "object": "chat.completion", + "choices": [ + { + "index": 0, + "finish_reason": "length", + "message": { + "role": "assistant", + "content": "\nOkay," + } + } + ] + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Perplexity/test/credentials/PerplexityApi.test.ts b/packages/nodes-base/nodes/Perplexity/test/credentials/PerplexityApi.test.ts new file mode 100644 index 0000000000..c4794d1491 --- /dev/null +++ b/packages/nodes-base/nodes/Perplexity/test/credentials/PerplexityApi.test.ts @@ -0,0 +1,52 @@ +import type { ICredentialDataDecryptedObject, IHttpRequestOptions } from 'n8n-workflow'; + +import { PerplexityApi } from '../../../../credentials/PerplexityApi.credentials'; + +describe('Perplexity API Credentials', () => { + describe('authenticate', () => { + const perplexityApi = new PerplexityApi(); + + it('should generate a valid authorization header', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'test-api-key', + baseUrl: 'https://api.perplexity.ai', + }; + + const requestOptions: IHttpRequestOptions = { + url: 'https://api.perplexity.ai/chat/completions', + method: 'POST', + body: { + model: 'r1-1776', + messages: [{ role: 'user', content: 'test' }], + }, + headers: { + 'Content-Type': 'application/json', + }, + json: true, + }; + + const authProperty = perplexityApi.authenticate; + + const result = { + ...requestOptions, + headers: { + ...requestOptions.headers, + Authorization: `Bearer ${credentials.apiKey}`, + }, + }; + + expect(result.headers?.Authorization).toBe('Bearer test-api-key'); + + expect(authProperty.type).toBe('generic'); + }); + }); + + describe('test', () => { + const perplexityApi = new PerplexityApi(); + + it('should have a valid test property', () => { + expect(perplexityApi.test).toBeDefined(); + expect(perplexityApi.test.request).toBeDefined(); + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 68b53e6e03..e15a79b6af 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -279,6 +279,7 @@ "dist/credentials/PagerDutyOAuth2Api.credentials.js", "dist/credentials/PayPalApi.credentials.js", "dist/credentials/PeekalinkApi.credentials.js", + "dist/credentials/PerplexityApi.credentials.js", "dist/credentials/PhantombusterApi.credentials.js", "dist/credentials/PhilipsHueOAuth2Api.credentials.js", "dist/credentials/PipedriveApi.credentials.js", @@ -702,6 +703,7 @@ "dist/nodes/PayPal/PayPal.node.js", "dist/nodes/PayPal/PayPalTrigger.node.js", "dist/nodes/Peekalink/Peekalink.node.js", + "dist/nodes/Perplexity/Perplexity.node.js", "dist/nodes/Phantombuster/Phantombuster.node.js", "dist/nodes/PhilipsHue/PhilipsHue.node.js", "dist/nodes/Pipedrive/Pipedrive.node.js",