diff --git a/packages/@n8n/nodes-langchain/credentials/OpenRouterApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/OpenRouterApi.credentials.ts new file mode 100644 index 0000000000..e21a84d020 --- /dev/null +++ b/packages/@n8n/nodes-langchain/credentials/OpenRouterApi.credentials.ts @@ -0,0 +1,47 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class OpenRouterApi implements ICredentialType { + name = 'openRouterApi'; + + displayName = 'OpenRouter'; + + documentationUrl = 'openrouter'; + + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + required: true, + default: '', + }, + { + displayName: 'Base URL', + name: 'url', + type: 'hidden', + default: 'https://openrouter.ai/api/v1', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: '={{ $credentials.url }}', + url: '/models', + }, + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index 230a6ddc6e..bf65b74d3f 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -91,6 +91,7 @@ function getInputs( '@n8n/n8n-nodes-langchain.lmChatGoogleVertex', '@n8n/n8n-nodes-langchain.lmChatMistralCloud', '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi', + '@n8n/n8n-nodes-langchain.lmChatOpenRouter', ], }, }, @@ -119,6 +120,7 @@ function getInputs( '@n8n/n8n-nodes-langchain.lmChatGroq', '@n8n/n8n-nodes-langchain.lmChatGoogleVertex', '@n8n/n8n-nodes-langchain.lmChatGoogleGemini', + '@n8n/n8n-nodes-langchain.lmChatOpenRouter', ], }, }, diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts new file mode 100644 index 0000000000..57a14028e7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts @@ -0,0 +1,252 @@ +/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ + +import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { + NodeConnectionType, + type INodeType, + type INodeTypeDescription, + type ISupplyDataFunctions, + type SupplyData, +} from 'n8n-workflow'; + +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + +import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; +import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; + +export class LmChatOpenRouter implements INodeType { + description: INodeTypeDescription = { + displayName: 'OpenRouter Chat Model', + name: 'lmChatOpenRouter', + icon: { light: 'file:openrouter.svg', dark: 'file:openrouter.dark.svg' }, + group: ['transform'], + version: [1], + description: 'For advanced usage with an AI chain', + defaults: { + name: 'OpenRouter Chat Model', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Language Models', 'Root Nodes'], + 'Language Models': ['Chat Models (Recommended)'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenrouter/', + }, + ], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: [], + // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong + outputs: [NodeConnectionType.AiLanguageModel], + outputNames: ['Model'], + credentials: [ + { + name: 'openRouterApi', + required: true, + }, + ], + requestDefaults: { + ignoreHttpStatusErrors: true, + baseURL: '={{ $credentials?.url }}', + }, + properties: [ + getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + { + displayName: + 'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + '/options.responseFormat': ['json_object'], + }, + }, + }, + { + displayName: 'Model', + name: 'model', + type: 'options', + description: + 'The model which will generate the completion. Learn more.', + typeOptions: { + loadOptions: { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + routing: { + send: { + type: 'body', + property: 'model', + }, + }, + default: 'openai/gpt-4o-mini', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + description: 'Additional options to add', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Frequency Penalty', + name: 'frequencyPenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim", + type: 'number', + }, + { + displayName: 'Maximum Number of Tokens', + name: 'maxTokens', + default: -1, + description: + 'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).', + type: 'number', + typeOptions: { + maxValue: 32768, + }, + }, + { + displayName: 'Response Format', + name: 'responseFormat', + default: 'text', + type: 'options', + options: [ + { + name: 'Text', + value: 'text', + description: 'Regular text response', + }, + { + name: 'JSON', + value: 'json_object', + description: + 'Enables JSON mode, which should guarantee the message the model generates is valid JSON', + }, + ], + }, + { + displayName: 'Presence Penalty', + name: 'presencePenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "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", + type: 'number', + }, + { + displayName: 'Sampling Temperature', + name: 'temperature', + default: 0.7, + typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 }, + description: + 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.', + type: 'number', + }, + { + displayName: 'Timeout', + name: 'timeout', + default: 360000, + description: 'Maximum amount of time a request is allowed to take in milliseconds', + type: 'number', + }, + { + displayName: 'Max Retries', + name: 'maxRetries', + default: 2, + description: 'Maximum number of retries to attempt', + type: 'number', + }, + { + displayName: 'Top P', + name: 'topP', + default: 1, + typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 }, + description: + 'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.', + type: 'number', + }, + ], + }, + ], + }; + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const credentials = await this.getCredentials('openRouterApi'); + + const modelName = this.getNodeParameter('model', itemIndex) as string; + + const options = this.getNodeParameter('options', itemIndex, {}) as { + frequencyPenalty?: number; + maxTokens?: number; + maxRetries: number; + timeout: number; + presencePenalty?: number; + temperature?: number; + topP?: number; + responseFormat?: 'text' | 'json_object'; + }; + + const configuration: ClientOptions = { + baseURL: credentials.url, + }; + + const model = new ChatOpenAI({ + openAIApiKey: credentials.apiKey, + modelName, + ...options, + timeout: options.timeout ?? 60000, + maxRetries: options.maxRetries ?? 2, + configuration, + callbacks: [new N8nLlmTracing(this)], + modelKwargs: options.responseFormat + ? { + response_format: { type: options.responseFormat }, + } + : undefined, + onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler), + }); + + return { + response: model, + }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/openrouter.dark.svg b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/openrouter.dark.svg new file mode 100644 index 0000000000..0b8bb9df1b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/openrouter.dark.svg @@ -0,0 +1 @@ +OpenRouter diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/openrouter.svg b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/openrouter.svg new file mode 100644 index 0000000000..749e44df71 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/openrouter.svg @@ -0,0 +1 @@ +OpenRouter diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index ccbeade1d6..63b1fae648 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -31,6 +31,7 @@ "dist/credentials/MotorheadApi.credentials.js", "dist/credentials/MistralCloudApi.credentials.js", "dist/credentials/OllamaApi.credentials.js", + "dist/credentials/OpenRouterApi.credentials.js", "dist/credentials/PineconeApi.credentials.js", "dist/credentials/QdrantApi.credentials.js", "dist/credentials/SerpApi.credentials.js", @@ -69,6 +70,7 @@ "dist/nodes/llms/LmChatGroq/LmChatGroq.node.js", "dist/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.js", "dist/nodes/llms/LMChatOllama/LmChatOllama.node.js", + "dist/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.js", "dist/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.js", "dist/nodes/llms/LMOpenAi/LmOpenAi.node.js", "dist/nodes/llms/LMCohere/LmCohere.node.js", diff --git a/packages/@n8n/nodes-langchain/types/types.ts b/packages/@n8n/nodes-langchain/types/types.ts new file mode 100644 index 0000000000..75d1044132 --- /dev/null +++ b/packages/@n8n/nodes-langchain/types/types.ts @@ -0,0 +1 @@ +type OpenAICompatibleCredential = { apiKey: string; url: string };