feat(Perplexity Node): Update Perplexity node for full API coverage (#26970)

Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
This commit is contained in:
Kesku
2026-03-20 10:18:29 +00:00
committed by GitHub
co-authored by Dimitri Lavrenük
parent 2ff73d5440
commit 483250d539
21 changed files with 1729 additions and 91 deletions
@@ -29,6 +29,8 @@ export class PerplexityApi implements ICredentialType {
properties: {
headers: {
Authorization: '=Bearer {{$credentials.apiKey}}',
'User-Agent': 'n8n',
'X-Source': 'n8n',
},
},
};
@@ -1,7 +1,9 @@
import type {
IExecuteSingleFunctions,
ILoadOptionsFunctions,
IN8nHttpFullResponse,
INodeExecutionData,
INodeListSearchResult,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
@@ -39,3 +41,57 @@ export async function sendErrorPostReceive(
}
return data;
}
const createErrorHandler = (description: string) =>
async function (
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
{ statusCode, body, statusMessage }: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (statusCode >= 400 && statusCode <= 599) {
const errorBody = body as JsonObject;
const error = (errorBody?.error ?? errorBody) as JsonObject;
const errorMessage =
typeof error.message === 'string'
? error.message
: (statusMessage ?? 'An unexpected issue occurred');
throw new NodeApiError(this.getNode(), errorBody, { message: errorMessage, description });
}
return data;
};
export const agentErrorPostReceive = createErrorHandler(
'Refer to the Agent API documentation at https://docs.perplexity.ai/api-reference/agent-post for valid parameters.',
);
export const searchErrorPostReceive = createErrorHandler(
'Refer to the Search API documentation at https://docs.perplexity.ai/api-reference/search-post for valid parameters.',
);
export const embeddingsErrorPostReceive = createErrorHandler(
'Refer to the Embeddings API documentation at https://docs.perplexity.ai/api-reference/embeddings-post for valid parameters.',
);
export async function getAgentModels(
this: ILoadOptionsFunctions,
filter: string = '',
): Promise<INodeListSearchResult> {
const response = (await this.helpers.requestWithAuthentication.call(this, 'perplexityApi', {
method: 'GET',
url: 'https://api.perplexity.ai/v1/models',
json: true,
})) as { data?: Array<{ id: string; owned_by: string }> };
const models = response.data ?? [];
const results = models
.map((model) => ({
name: model.id,
value: model.id,
url: 'https://docs.perplexity.ai/docs/agent-api/models',
}))
.filter((item) => !filter || item.name.toLowerCase().includes(filter.toLowerCase()));
return { results };
}
@@ -1,8 +1,8 @@
{
"node": "n8n-nodes-base.perplexity",
"nodeVersion": "1.0",
"nodeVersion": "1.1",
"codexVersion": "1.0",
"categories": ["Utility"],
"categories": ["AI", "Utility"],
"resources": {
"credentialDocumentation": [
{
@@ -1,7 +1,8 @@
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { chat } from './descriptions';
import { agent, chat, embeddings, search } from './descriptions';
import { getAgentModels } from './GenericFunctions';
export class Perplexity implements INodeType {
description: INodeTypeDescription = {
@@ -12,10 +13,11 @@ export class Perplexity implements INodeType {
dark: 'file:perplexity.dark.svg',
},
group: ['transform'],
version: 1,
version: [1, 2],
defaultVersion: 2,
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
description:
' AI-powered answer engine that provides accurate, trusted, and real-time answers to any question. Generates AI responses with citations',
'AI-powered answer engine that provides accurate, trusted, and real-time answers to any question. Supports chat completions, agent responses, web search, and embeddings.',
defaults: {
name: 'Perplexity',
},
@@ -33,6 +35,7 @@ export class Perplexity implements INodeType {
ignoreHttpStatusErrors: true,
},
properties: [
// V1: hidden resource selector (only chat)
{
displayName: 'Resource',
name: 'resource',
@@ -45,8 +48,58 @@ export class Perplexity implements INodeType {
},
],
default: 'chat',
displayOptions: {
show: {
'@version': [1],
},
},
},
// V2: visible resource selector (all resources)
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Agent',
value: 'agent',
description:
'Create responses using the Agent API with third-party models, presets, tools, and structured outputs',
},
{
name: 'Chat',
value: 'chat',
description: 'Send messages using Sonar models with built-in web search',
},
{
name: 'Embedding',
value: 'embedding',
description: 'Generate vector embeddings for text',
},
{
name: 'Search',
value: 'search',
description: 'Get raw, ranked web search results',
},
],
default: 'chat',
displayOptions: {
show: {
'@version': [2],
},
},
},
...agent.description,
...chat.description,
...embeddings.description,
...search.description,
],
};
methods = {
listSearch: {
getAgentModels,
},
};
}
@@ -0,0 +1,39 @@
import type { INodeProperties } from 'n8n-workflow';
import { agentErrorPostReceive } from '../../GenericFunctions';
import * as createResponse from './createResponse.operation';
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['agent'],
},
},
options: [
{
name: 'Create Response',
value: 'createResponse',
action: 'Create a response',
description:
'Create a response using the Agent API with third-party models, presets, and tools',
routing: {
request: {
method: 'POST',
url: '/v1/agent',
},
output: {
postReceive: [agentErrorPostReceive],
},
},
},
],
default: 'createResponse',
},
...createResponse.description,
];
@@ -0,0 +1,220 @@
import type { INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
const properties: INodeProperties[] = [
{
displayName: 'Input',
name: 'input',
type: 'string',
required: true,
default: '',
typeOptions: { rows: 3 },
description: 'The input text prompt to send to the agent',
routing: {
send: {
type: 'body',
property: 'input',
},
},
},
{
displayName: 'Model',
name: 'model',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description:
'The model to use. Uses provider/model format (e.g. openai/gpt-5.2). Leave empty when using a preset.',
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'getAgentModels',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
placeholder: 'e.g. openai/gpt-5.2',
},
],
routing: {
send: {
type: 'body',
property: 'model',
value: '={{ $value }}',
},
},
},
{
displayName: 'Preset',
name: 'preset',
type: 'string',
default: '',
description: 'Preset name to use. Use preset OR model, not both.',
hint: 'Use a preset OR a model, not both',
routing: {
send: {
type: 'body',
property: 'preset',
},
},
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: false,
description: 'Whether to return only essential fields (ID, model, output text, citations)',
routing: {
output: {
postReceive: [
{
type: 'set',
enabled: '={{ $value }}',
properties: {
value:
'={{ { "id": $response.body?.id, "model": $response.body?.model, "output_text": $response.body?.output?.find(o => o.type === "message")?.content?.find(c => c.type === "output_text")?.text, "citations": $response.body?.output?.find(o => o.type === "search_results")?.results?.map(r => ({ title: r.title, url: r.url })), "usage": $response.body?.usage } }}',
},
},
],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Instructions',
name: 'instructions',
type: 'string',
default: '',
typeOptions: { rows: 3 },
description: 'System-level instructions for the agent',
routing: {
send: {
type: 'body',
property: 'instructions',
},
},
},
{
displayName: 'Language Preference',
name: 'languagePreference',
type: 'string',
default: '',
placeholder: 'e.g. en',
description: 'ISO 639-1 language code for the response language preference',
routing: {
send: {
type: 'body',
property: 'language_preference',
},
},
},
{
displayName: 'Max Output Tokens',
name: 'maxOutputTokens',
type: 'number',
default: 1024,
typeOptions: { minValue: 1 },
description: 'The maximum number of tokens to generate in the response',
routing: {
send: {
type: 'body',
property: 'max_output_tokens',
},
},
},
{
displayName: 'Max Steps',
name: 'maxSteps',
type: 'number',
default: 5,
typeOptions: { minValue: 1, maxValue: 10 },
description: 'Maximum number of agentic steps (1-10)',
routing: {
send: {
type: 'body',
property: 'max_steps',
},
},
},
{
displayName: 'Models (Fallback)',
name: 'modelsFallback',
type: 'string',
default: '',
placeholder: 'e.g. openai/gpt-5.2,anthropic/claude-sonnet-4-6',
description: 'Comma-separated list of 1-5 model IDs to use as fallbacks',
routing: {
send: {
type: 'body',
property: 'models',
value: '={{ $value.split(",").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Reasoning',
name: 'reasoning',
type: 'json',
default: '',
description: 'Reasoning configuration object (e.g. {"effort": "high"})',
routing: {
send: {
type: 'body',
property: 'reasoning',
value: '={{ JSON.parse($value) }}',
},
},
},
{
displayName: 'Response Format',
name: 'responseFormat',
type: 'json',
default: '',
description:
'JSON schema for structured output. Set type to "json_schema" with a schema property.',
routing: {
send: {
type: 'body',
property: 'response_format',
value: '={{ JSON.parse($value) }}',
},
},
},
{
displayName: 'Tools',
name: 'tools',
type: 'json',
default: '',
placeholder: 'e.g. [{"type":"web_search"},{"type":"fetch_url"}]',
description: 'Array of tool objects to make available to the agent',
routing: {
send: {
type: 'body',
property: 'tools',
value: '={{ JSON.parse($value) }}',
},
},
},
],
},
];
const displayOptions = {
show: {
resource: ['agent'],
operation: ['createResponse'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
@@ -12,7 +12,6 @@ const properties: INodeProperties[] = [
{ 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',
@@ -125,6 +124,42 @@ const properties: INodeProperties[] = [
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Disable Search',
name: 'disableSearch',
type: 'boolean',
default: false,
description: 'Whether to disable web search for this request',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'disable_search',
},
},
},
{
displayName: 'Enable Search Classifier',
name: 'enableSearchClassifier',
type: 'boolean',
default: false,
description: 'Whether to enable the search classifier',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'enable_search_classifier',
},
},
},
{
displayName: 'Frequency Penalty',
name: 'frequencyPenalty',
@@ -142,6 +177,103 @@ const properties: INodeProperties[] = [
},
},
},
{
displayName: 'Image Domain Filter',
name: 'imageDomainFilter',
type: 'string',
default: '',
placeholder: 'e.g. domain1.com,domain2.com',
description: 'Comma-separated list of domains to filter image results from',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'image_domain_filter',
value: '={{ $value.split(",").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Image Format Filter',
name: 'imageFormatFilter',
type: 'string',
default: '',
placeholder: 'e.g. jpg,png',
description: 'Comma-separated list of image formats to filter results by',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'image_format_filter',
value: '={{ $value.split(",").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Language Preference',
name: 'languagePreference',
type: 'string',
default: '',
placeholder: 'e.g. en',
description: 'ISO 639-1 language code for the response language preference',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'language_preference',
},
},
},
{
displayName: 'Last Updated After',
name: 'lastUpdatedAfter',
type: 'string',
default: '',
placeholder: 'e.g. 01/01/2024',
description: 'Filter results last updated after this date (MM/DD/YYYY)',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'last_updated_after_filter',
},
},
},
{
displayName: 'Last Updated Before',
name: 'lastUpdatedBefore',
type: 'string',
default: '',
placeholder: 'e.g. 12/31/2024',
description: 'Filter results last updated before this date (MM/DD/YYYY)',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'last_updated_before_filter',
},
},
},
{
displayName: 'Maximum Number of Tokens',
name: 'maxTokens',
@@ -174,6 +306,234 @@ const properties: INodeProperties[] = [
},
},
},
{
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: 'Reasoning Effort',
name: 'reasoningEffort',
type: 'options',
options: [
{ name: 'Minimal', value: 'minimal' },
{ name: 'Low', value: 'low' },
{ name: 'Medium', value: 'medium' },
{ name: 'High', value: 'high' },
],
default: 'medium',
description: 'The level of reasoning effort to apply',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'reasoning_effort',
},
},
},
{
displayName: 'Response Format',
name: 'responseFormat',
type: 'json',
default: '',
description:
'JSON schema for structured output. Set type to "json_schema" with a schema property.',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'response_format',
value: '={{ JSON.parse($value) }}',
},
},
},
{
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 After Date',
name: 'searchAfterDate',
type: 'string',
default: '',
placeholder: 'e.g. 01/01/2024',
description: 'Filter results published after this date (MM/DD/YYYY)',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'search_after_date_filter',
},
},
},
{
displayName: 'Search Before Date',
name: 'searchBeforeDate',
type: 'string',
default: '',
placeholder: 'e.g. 12/31/2024',
description: 'Filter results published before this date (MM/DD/YYYY)',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'search_before_date_filter',
},
},
},
{
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 <code>-</code> to the beginning of the domain string (e.g., <code>-domain1</code>). 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 Language Filter',
name: 'searchLanguageFilter',
type: 'string',
default: '',
placeholder: 'e.g. en,fr,de',
description:
'Comma-separated list of ISO 639-1 language codes to filter results by (max 20)',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'search_language_filter',
value: '={{ $value.split(",").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Search Mode',
name: 'searchMode',
type: 'options',
options: [
{ name: 'Web', value: 'web' },
{ name: 'Academic', value: 'academic' },
{ name: 'SEC', value: 'sec' },
],
default: 'web',
description: 'The search mode to use for retrieving information',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'search_mode',
},
},
},
{
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' },
{ name: 'Year', value: 'year' },
],
default: 'month',
description: 'Returns search results within the specified time interval',
routing: {
send: {
type: 'body',
property: 'search_recency_filter',
},
},
},
{
displayName: 'Stop Sequences',
name: 'stop',
type: 'string',
default: '',
placeholder: 'e.g. stop1,stop2',
description: 'Comma-separated list of sequences where the model should stop generating',
displayOptions: {
show: {
'@version': [2],
},
},
routing: {
send: {
type: 'body',
property: 'stop',
value: '={{ $value.split(",").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Top K',
name: 'topK',
@@ -211,95 +571,21 @@ const properties: INodeProperties[] = [
},
},
{
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',
displayName: 'Web Search Options',
name: 'webSearchOptions',
type: 'json',
default: '',
description:
'Limit the citations used by the online model to URLs from the specified domains. For blacklisting, add a <code>-</code> to the beginning of the domain string (e.g., <code>-domain1</code>). 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()) }}',
description: 'Advanced web search configuration object',
displayOptions: {
show: {
'@version': [2],
},
},
},
{
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',
property: 'web_search_options',
value: '={{ JSON.parse($value) }}',
},
},
},
@@ -0,0 +1,55 @@
import type { INodeProperties } from 'n8n-workflow';
import { embeddingsErrorPostReceive } from '../../GenericFunctions';
import * as createContextualized from './createContextualized.operation';
import * as createEmbedding from './createEmbedding.operation';
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['embedding'],
},
},
options: [
{
name: 'Create Embedding',
value: 'createEmbedding',
action: 'Create an embedding',
description: 'Generate vector embeddings for text input',
routing: {
request: {
method: 'POST',
url: '/v1/embeddings',
},
output: {
postReceive: [embeddingsErrorPostReceive],
},
},
},
{
name: 'Create Contextualized Embedding',
value: 'createContextualized',
action: 'Create a contextualized embedding',
description: 'Generate context-aware embeddings for document chunks',
routing: {
request: {
method: 'POST',
url: '/v1/contextualizedembeddings',
},
output: {
postReceive: [embeddingsErrorPostReceive],
},
},
},
],
default: 'createEmbedding',
},
...createEmbedding.description,
...createContextualized.description,
];
@@ -0,0 +1,90 @@
import type { INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
const properties: INodeProperties[] = [
{
displayName: 'Model',
name: 'model',
type: 'options',
required: true,
options: [{ name: 'PPLX Embed Context V1 4B', value: 'pplx-embed-context-v1-4b' }],
default: 'pplx-embed-context-v1-4b',
description: 'The contextualized embedding model to use',
routing: {
send: {
type: 'body',
property: 'model',
},
},
},
{
displayName: 'Input Documents',
name: 'input',
type: 'json',
required: true,
default: '[["paragraph 1 of doc A", "paragraph 2 of doc A"], ["paragraph 1 of doc B"]]',
description: 'Array of documents, where each document is an array of paragraph strings',
routing: {
send: {
type: 'body',
property: 'input',
value: '={{ JSON.parse($value) }}',
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Dimensions',
name: 'dimensions',
type: 'number',
default: 0,
typeOptions: {
minValue: 0,
numberPrecision: 0,
},
description:
'Number of dimensions for the output embedding. If 0 or unset, the full model dimensions are used.',
routing: {
send: {
type: 'body',
property: 'dimensions',
value: '={{ $value || undefined }}',
},
},
},
{
displayName: 'Encoding Format',
name: 'encoding_format',
type: 'options',
default: 'base64_int8',
options: [
{ name: 'Base64 Int8', value: 'base64_int8' },
{ name: 'Base64 Binary', value: 'base64_binary' },
],
description:
'The format of the returned embeddings. Float is not supported by this endpoint.',
routing: {
send: {
type: 'body',
property: 'encoding_format',
},
},
},
],
},
];
const displayOptions = {
show: {
resource: ['embedding'],
operation: ['createContextualized'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
@@ -0,0 +1,94 @@
import type { INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
const properties: INodeProperties[] = [
{
displayName: 'Model',
name: 'model',
type: 'options',
required: true,
options: [
{ name: 'PPLX Embed V1 0.6B', value: 'pplx-embed-v1-0.6b' },
{ name: 'PPLX Embed V1 4B', value: 'pplx-embed-v1-4b' },
],
default: 'pplx-embed-v1-4b',
description: 'The embedding model to use',
routing: {
send: {
type: 'body',
property: 'model',
},
},
},
{
displayName: 'Input Texts',
name: 'input',
type: 'string',
required: true,
default: '',
typeOptions: { rows: 4 },
placeholder: 'One text per line',
description: 'Text(s) to embed. Put each text on a separate line.',
routing: {
send: {
type: 'body',
property: 'input',
value: '={{ $value.split("\\n").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Dimensions',
name: 'dimensions',
type: 'number',
default: 0,
typeOptions: {
minValue: 0,
numberPrecision: 0,
},
description:
'Number of dimensions for the output embedding. If 0 or unset, the full model dimensions are used.',
routing: {
send: {
type: 'body',
property: 'dimensions',
value: '={{ $value || undefined }}',
},
},
},
{
displayName: 'Encoding Format',
name: 'encoding_format',
type: 'options',
default: 'base64_int8',
options: [
{ name: 'Base64 Int8', value: 'base64_int8' },
{ name: 'Base64 Binary', value: 'base64_binary' },
],
description: 'The format of the returned embeddings',
routing: {
send: {
type: 'body',
property: 'encoding_format',
},
},
},
],
},
];
const displayOptions = {
show: {
resource: ['embedding'],
operation: ['createEmbedding'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
@@ -1 +1,4 @@
export * as agent from './agent/Agent.resource';
export * as chat from './chat/Chat.resource';
export * as embeddings from './embeddings/Embeddings.resource';
export * as search from './search/Search.resource';
@@ -0,0 +1,38 @@
import type { INodeProperties } from 'n8n-workflow';
import { searchErrorPostReceive } from '../../GenericFunctions';
import * as searchOp from './search.operation';
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['search'],
},
},
options: [
{
name: 'Search',
value: 'search',
action: 'Search the web',
description: 'Get raw, ranked web search results with filtering and content extraction',
routing: {
request: {
method: 'POST',
url: '/search',
},
output: {
postReceive: [searchErrorPostReceive],
},
},
},
],
default: 'search',
},
...searchOp.description,
];
@@ -0,0 +1,220 @@
import type { INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
const properties: INodeProperties[] = [
{
displayName: 'Query',
name: 'query',
type: 'string',
required: true,
default: '',
description: 'The search query string',
routing: {
send: {
type: 'body',
property: 'query',
},
},
},
{
displayName: 'Simplify Output',
name: 'simplify',
type: 'boolean',
default: false,
description: 'Whether to return only the ID and results array',
routing: {
output: {
postReceive: [
{
type: 'set',
enabled: '={{ $value }}',
properties: {
value: '={{ { "id": $response.body?.id, "results": $response.body?.results } }}',
},
},
],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Country',
name: 'country',
type: 'string',
default: '',
placeholder: 'e.g. us',
description: '2-character ISO 3166-1 alpha-2 country code to localize search results',
routing: {
send: {
type: 'body',
property: 'country',
},
},
},
{
displayName: 'Last Updated After',
name: 'lastUpdatedAfter',
type: 'string',
default: '',
placeholder: 'e.g. 01/01/2024',
description: 'Filter results last updated after this date (MM/DD/YYYY)',
routing: {
send: {
type: 'body',
property: 'last_updated_after_filter',
},
},
},
{
displayName: 'Last Updated Before',
name: 'lastUpdatedBefore',
type: 'string',
default: '',
placeholder: 'e.g. 12/31/2024',
description: 'Filter results last updated before this date (MM/DD/YYYY)',
routing: {
send: {
type: 'body',
property: 'last_updated_before_filter',
},
},
},
{
displayName: 'Max Results',
name: 'maxResults',
type: 'number',
default: 10,
typeOptions: { minValue: 1, maxValue: 20 },
description: 'Maximum number of search results to return (1-20)',
routing: {
send: {
type: 'body',
property: 'max_results',
},
},
},
{
displayName: 'Max Tokens',
name: 'maxTokens',
type: 'number',
default: 10000,
typeOptions: { minValue: 1, maxValue: 1000000 },
description: 'Maximum number of tokens in the response',
routing: {
send: {
type: 'body',
property: 'max_tokens',
},
},
},
{
displayName: 'Max Tokens Per Page',
name: 'maxTokensPerPage',
type: 'number',
default: 4096,
typeOptions: { minValue: 1, maxValue: 1000000 },
description: 'Maximum number of tokens per page of results',
routing: {
send: {
type: 'body',
property: 'max_tokens_per_page',
},
},
},
{
displayName: 'Search After Date',
name: 'searchAfterDate',
type: 'string',
default: '',
placeholder: 'e.g. 01/01/2024',
description: 'Filter results published after this date (MM/DD/YYYY)',
routing: {
send: {
type: 'body',
property: 'search_after_date_filter',
},
},
},
{
displayName: 'Search Before Date',
name: 'searchBeforeDate',
type: 'string',
default: '',
placeholder: 'e.g. 12/31/2024',
description: 'Filter results published before this date (MM/DD/YYYY)',
routing: {
send: {
type: 'body',
property: 'search_before_date_filter',
},
},
},
{
displayName: 'Search Domain Filter',
name: 'searchDomainFilter',
type: 'string',
default: '',
placeholder: 'e.g. domain1.com,domain2.com',
description: 'Comma-separated list of domains to limit search results to (max 20)',
routing: {
send: {
type: 'body',
property: 'search_domain_filter',
value: '={{ $value.split(",").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Search Language Filter',
name: 'searchLanguageFilter',
type: 'string',
default: '',
placeholder: 'e.g. en,fr,de',
description:
'Comma-separated list of ISO 639-1 language codes to filter results by (max 20)',
routing: {
send: {
type: 'body',
property: 'search_language_filter',
value: '={{ $value.split(",").map(s => s.trim()).filter(s => s) }}',
},
},
},
{
displayName: 'Search Recency Filter',
name: 'searchRecencyFilter',
type: 'options',
options: [
{ name: 'Day', value: 'day' },
{ name: 'Hour', value: 'hour' },
{ name: 'Month', value: 'month' },
{ name: 'Week', value: 'week' },
{ name: 'Year', value: 'year' },
],
default: 'month',
description: 'Filter search results by publication recency',
routing: {
send: {
type: 'body',
property: 'search_recency_filter',
},
},
},
],
},
];
const displayOptions = {
show: {
resource: ['search'],
operation: ['search'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
@@ -5,7 +5,12 @@ import type {
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { sendErrorPostReceive } from '../GenericFunctions';
import {
agentErrorPostReceive,
embeddingsErrorPostReceive,
searchErrorPostReceive,
sendErrorPostReceive,
} from '../GenericFunctions';
// Mock implementation for `this` in `sendErrorPostReceive`
const mockExecuteSingleFunctions = {
@@ -202,4 +207,167 @@ describe('Generic Functions', () => {
);
});
});
describe('agentErrorPostReceive', () => {
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 agentErrorPostReceive.call(
mockExecuteSingleFunctions,
testData,
testResponse,
);
expect(result).toEqual(testData);
});
it('should throw NodeApiError if status code is 4xx', async () => {
testResponse.statusCode = 400;
testResponse.body = { error: { message: 'Bad request' } };
await expect(
agentErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrow(NodeApiError);
});
it('should throw NodeApiError if status code is 5xx', async () => {
testResponse.statusCode = 500;
testResponse.body = { error: { message: 'Server error' } };
await expect(
agentErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrow(NodeApiError);
});
it('should use statusMessage as fallback when error message is not a string', async () => {
const errorResponse = {
statusCode: 400,
statusMessage: 'Bad Request',
headers: {},
body: { error: { message: { detail: 'complex error' } } },
};
await expect(
agentErrorPostReceive.call(
mockExecuteSingleFunctions,
testData,
errorResponse as unknown as IN8nHttpFullResponse,
),
).rejects.toThrow(NodeApiError);
});
it('should include agent API documentation in the description', async () => {
testResponse.statusCode = 400;
testResponse.body = { error: { message: 'Invalid param' } };
await expect(
agentErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrowError(
expect.objectContaining({
description: expect.stringContaining(
'https://docs.perplexity.ai/api-reference/agent-post',
),
}),
);
});
});
describe('searchErrorPostReceive', () => {
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 searchErrorPostReceive.call(
mockExecuteSingleFunctions,
testData,
testResponse,
);
expect(result).toEqual(testData);
});
it('should throw NodeApiError if status code is 4xx', async () => {
testResponse.statusCode = 400;
testResponse.body = { error: { message: 'Bad request' } };
await expect(
searchErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrow(NodeApiError);
});
it('should throw NodeApiError if status code is 5xx', async () => {
testResponse.statusCode = 500;
testResponse.body = { error: { message: 'Server error' } };
await expect(
searchErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrow(NodeApiError);
});
it('should include search API documentation in the description', async () => {
testResponse.statusCode = 400;
testResponse.body = { error: { message: 'Invalid query' } };
await expect(
searchErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrowError(
expect.objectContaining({
description: expect.stringContaining(
'https://docs.perplexity.ai/api-reference/search-post',
),
}),
);
});
});
describe('embeddingsErrorPostReceive', () => {
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 embeddingsErrorPostReceive.call(
mockExecuteSingleFunctions,
testData,
testResponse,
);
expect(result).toEqual(testData);
});
it('should throw NodeApiError if status code is 4xx', async () => {
testResponse.statusCode = 400;
testResponse.body = { error: { message: 'Bad request' } };
await expect(
embeddingsErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrow(NodeApiError);
});
it('should throw NodeApiError if status code is 5xx', async () => {
testResponse.statusCode = 500;
testResponse.body = { error: { message: 'Server error' } };
await expect(
embeddingsErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrow(NodeApiError);
});
it('should include embeddings API documentation in the description', async () => {
testResponse.statusCode = 400;
testResponse.body = { error: { message: 'Invalid input' } };
await expect(
embeddingsErrorPostReceive.call(mockExecuteSingleFunctions, testData, testResponse),
).rejects.toThrowError(
expect.objectContaining({
description: expect.stringContaining(
'https://docs.perplexity.ai/api-reference/embeddings-post',
),
}),
);
});
});
});
@@ -2,7 +2,7 @@ import { Perplexity } from '../../Perplexity/Perplexity.node';
import { description } from '../descriptions/chat/complete.operation';
jest.mock('../../Perplexity/GenericFunctions', () => ({
getModels: jest.fn(),
getAgentModels: jest.fn(),
}));
describe('Perplexity Node', () => {
@@ -0,0 +1,39 @@
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 - Agent Create Response', () => {
beforeEach(() => {
nock.disableNetConnect();
nock('https://api.perplexity.ai')
.post('/v1/agent', (body) => {
return typeof body?.input === 'string';
})
.reply(200, {
id: 'resp_test123',
model: 'openai/gpt-5.2',
object: 'response',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: '2+2 equals 4.' }],
},
],
});
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
new NodeTestHarness().setupTests({ credentials });
});
@@ -0,0 +1,68 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-80, -680],
"id": "a9105e3d-172b-411e-8c06-767a3ce1003a",
"name": "When clicking 'Test workflow'"
},
{
"parameters": {
"resource": "agent",
"operation": "createResponse",
"model": {
"__rl": true,
"value": "openai/gpt-5.2",
"mode": "id"
},
"input": "What is 2+2?",
"requestOptions": {}
},
"type": "n8n-nodes-base.perplexity",
"typeVersion": 2,
"position": [-40, -380],
"id": "agent-node-1",
"name": "Agent Response",
"credentials": {
"perplexityApi": {
"id": "test",
"name": "Perplexity account"
}
}
}
],
"connections": {
"When clicking 'Test workflow'": {
"main": [
[
{
"node": "Agent Response",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"Agent Response": [
{
"json": {
"id": "resp_test123",
"model": "openai/gpt-5.2",
"object": "response",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": "2+2 equals 4." }]
}
]
}
}
]
}
}
@@ -0,0 +1,41 @@
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 - Create Embedding', () => {
beforeEach(() => {
nock.disableNetConnect();
nock('https://api.perplexity.ai')
.post('/v1/embeddings', (body) => {
return Array.isArray(body?.input) && typeof body?.model === 'string';
})
.reply(200, {
object: 'list',
data: [
{
object: 'embedding',
index: 0,
embedding: 'base64encodedstring...',
},
],
model: 'pplx-embed-v1-4b',
usage: {
prompt_tokens: 10,
total_tokens: 10,
},
});
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
new NodeTestHarness().setupTests({ credentials });
});
@@ -0,0 +1,66 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-80, -680],
"id": "trigger-1",
"name": "When clicking 'Test workflow'"
},
{
"parameters": {
"resource": "embedding",
"operation": "createEmbedding",
"model": "pplx-embed-v1-4b",
"input": "Hello world\nThis is a test",
"requestOptions": {}
},
"type": "n8n-nodes-base.perplexity",
"typeVersion": 2,
"position": [-40, -380],
"id": "embed-node-1",
"name": "Create Embedding",
"credentials": {
"perplexityApi": {
"id": "test",
"name": "Perplexity account"
}
}
}
],
"connections": {
"When clicking 'Test workflow'": {
"main": [
[
{
"node": "Create Embedding",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"Create Embedding": [
{
"json": {
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": "base64encodedstring..."
}
],
"model": "pplx-embed-v1-4b",
"usage": {
"prompt_tokens": 10,
"total_tokens": 10
}
}
}
]
}
}
@@ -0,0 +1,38 @@
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 - Search', () => {
beforeEach(() => {
nock.disableNetConnect();
nock('https://api.perplexity.ai')
.post('/search', (body) => {
return typeof body?.query === 'string';
})
.reply(200, {
id: 'search-test123',
results: [
{
title: 'AI Developments 2024',
url: 'https://example.com/ai',
snippet: 'Latest AI news...',
date: '2024-12-15',
last_updated: '2024-12-20',
},
],
});
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
new NodeTestHarness().setupTests({ credentials });
});
@@ -0,0 +1,62 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-80, -680],
"id": "trigger-1",
"name": "When clicking 'Test workflow'"
},
{
"parameters": {
"resource": "search",
"operation": "search",
"query": "latest AI developments",
"requestOptions": {}
},
"type": "n8n-nodes-base.perplexity",
"typeVersion": 2,
"position": [-40, -380],
"id": "search-node-1",
"name": "Search",
"credentials": {
"perplexityApi": {
"id": "test",
"name": "Perplexity account"
}
}
}
],
"connections": {
"When clicking 'Test workflow'": {
"main": [
[
{
"node": "Search",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"Search": [
{
"json": {
"id": "search-test123",
"results": [
{
"title": "AI Developments 2024",
"url": "https://example.com/ai",
"snippet": "Latest AI news...",
"date": "2024-12-15",
"last_updated": "2024-12-20"
}
]
}
}
]
}
}