feat(Perplexity Node): New node (#13604)

This commit is contained in:
Stanimira Rikova
2025-05-29 22:01:19 +03:00
committed by GitHub
parent 041ada1fd6
commit 6d3e6eef00
14 changed files with 988 additions and 0 deletions

View File

@@ -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].',
}),
);
});
});
});

View File

@@ -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));
});
});
});

View File

@@ -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: '<think>\nOkay,',
},
},
],
});
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
new NodeTestHarness().setupTests({ credentials });
});

View File

@@ -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": "<think>\nOkay,"
}
}
]
}
}
]
}
}

View File

@@ -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();
});
});
});