From d7dcba5c803578ee663ffd43f570ab1503fe407c Mon Sep 17 00:00:00 2001 From: Alejandro Sanz Date: Tue, 11 Aug 2026 14:05:55 +0200 Subject: [PATCH] feat(lmChatOpenRouter Node): Add provider routing options (#24822) Co-authored-by: Dawid Myslak --- .../LmChatOpenRouter/LmChatOpenRouter.node.ts | 150 +++++++++++++++++- .../test/LmChatOpenRouter.test.ts | 109 +++++++++++++ 2 files changed, 254 insertions(+), 5 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts index e12430c6e1f..44359363fb3 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts @@ -275,6 +275,91 @@ export class LmChatOpenRouter implements INodeType { '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', }, + { + displayName: 'Provider Routing', + name: 'providerRouting', + type: 'collection', + default: {}, + description: + 'Configure which sub-providers handle your requests. Learn more.', + placeholder: 'Add Provider Routing Option', + options: [ + { + displayName: 'Order', + name: 'order', + type: 'string', + default: '', + placeholder: 'anthropic,openai,google', + description: + 'Comma-separated list of provider slugs to try in order. Learn more.', + }, + { + displayName: 'Allow Fallbacks', + name: 'allowFallbacks', + type: 'boolean', + default: true, + description: 'Whether to allow backup providers when the primary is unavailable', + }, + { + displayName: 'Require Parameters', + name: 'requireParameters', + type: 'boolean', + default: false, + description: + 'Whether to only use providers that support all parameters in your request', + }, + { + displayName: 'Data Collection', + name: 'dataCollection', + type: 'options', + options: [ + { name: 'Allow', value: 'allow' }, + { name: 'Deny', value: 'deny' }, + ], + default: 'allow', + description: + "Select 'Deny' to route requests only through providers that don't collect your data", + }, + { + displayName: 'Zero Data Retention (ZDR)', + name: 'zdr', + type: 'boolean', + default: false, + description: + 'Whether to restrict routing to only providers with Zero Data Retention endpoints', + }, + { + displayName: 'Only', + name: 'only', + type: 'string', + default: '', + placeholder: 'azure,anthropic', + description: + 'Comma-separated list of provider slugs to allow for this request. Only these providers will be used. Learn more.', + }, + { + displayName: 'Ignore', + name: 'ignore', + type: 'string', + default: '', + placeholder: 'anthropic,openai', + description: 'Comma-separated list of provider slugs to skip for this request', + }, + { + displayName: 'Sort', + name: 'sort', + type: 'options', + options: [ + { name: 'Price', value: 'price' }, + { name: 'Throughput', value: 'throughput' }, + { name: 'Latency', value: 'latency' }, + ], + default: '', + description: + 'Sort providers by a specific attribute. Disables load balancing and tries providers in order. Learn more.', + }, + ], + }, ], }, ], @@ -294,6 +379,16 @@ export class LmChatOpenRouter implements INodeType { temperature?: number; topP?: number; responseFormat?: 'text' | 'json_object'; + providerRouting?: { + order?: string; + allowFallbacks?: boolean; + requireParameters?: boolean; + dataCollection?: 'allow' | 'deny'; + zdr?: boolean; + only?: string; + ignore?: string; + sort?: 'price' | 'throughput' | 'latency'; + }; }; const timeout = options.timeout; @@ -308,6 +403,55 @@ export class LmChatOpenRouter implements INodeType { }, }; + // Build provider routing object + const provider: Record = {}; + if (options.providerRouting) { + const routing = options.providerRouting; + + if (routing.order) { + provider.order = routing.order + .split(',') + .map((p) => p.trim()) + .filter((p) => p); + } + if (routing.allowFallbacks !== undefined) { + provider.allow_fallbacks = routing.allowFallbacks; + } + if (routing.requireParameters !== undefined) { + provider.require_parameters = routing.requireParameters; + } + if (routing.dataCollection) { + provider.data_collection = routing.dataCollection; + } + if (routing.zdr !== undefined) { + provider.zdr = routing.zdr; + } + if (routing.only) { + provider.only = routing.only + .split(',') + .map((p) => p.trim()) + .filter((p) => p); + } + if (routing.ignore) { + provider.ignore = routing.ignore + .split(',') + .map((p) => p.trim()) + .filter((p) => p); + } + if (routing.sort) { + provider.sort = routing.sort; + } + } + + // Build modelKwargs + const modelKwargs: Record = {}; + if (options.responseFormat) { + modelKwargs.response_format = { type: options.responseFormat }; + } + if (Object.keys(provider).length > 0) { + modelKwargs.provider = provider; + } + const model = new ChatOpenAI({ apiKey: credentials.apiKey, model: modelName, @@ -316,11 +460,7 @@ export class LmChatOpenRouter implements INodeType { maxRetries: options.maxRetries ?? 2, configuration, callbacks: [new N8nLlmTracing(this)], - modelKwargs: options.responseFormat - ? { - response_format: { type: options.responseFormat }, - } - : undefined, + modelKwargs: Object.keys(modelKwargs).length > 0 ? modelKwargs : undefined, onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler), }); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/test/LmChatOpenRouter.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/test/LmChatOpenRouter.test.ts index 2a44428e22a..ec102bda183 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/test/LmChatOpenRouter.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/test/LmChatOpenRouter.test.ts @@ -166,6 +166,115 @@ describe('LmChatOpenRouter', () => { const callArgs = MockedChatOpenAI.mock.calls[0][0]; expect(callArgs?.configuration?.fetch).toEqual(expect.any(Function)); }); + + describe('provider routing options', () => { + it('should handle order option with comma-separated providers and spaces', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514'; + if (paramName === 'options') { + return { providerRouting: { order: ' anthropic , openai ,, google ' } }; + } + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: expect.objectContaining({ + provider: expect.objectContaining({ + order: ['anthropic', 'openai', 'google'], + }), + }), + }), + ); + }); + + it('should map provider routing fields to OpenRouter payload', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514'; + if (paramName === 'options') { + return { + providerRouting: { + allowFallbacks: false, + requireParameters: true, + dataCollection: 'deny', + zdr: true, + only: 'azure,anthropic', + ignore: 'google', + sort: 'price', + }, + }; + } + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: expect.objectContaining({ + provider: expect.objectContaining({ + allow_fallbacks: false, + require_parameters: true, + data_collection: 'deny', + zdr: true, + only: ['azure', 'anthropic'], + ignore: ['google'], + sort: 'price', + }), + }), + }), + ); + }); + + it('should include response format and provider routing together', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514'; + if (paramName === 'options') { + return { + responseFormat: 'json_object', + providerRouting: { + order: 'anthropic,openai', + sort: 'price', + }, + }; + } + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: expect.objectContaining({ + response_format: { type: 'json_object' }, + provider: expect.objectContaining({ + order: ['anthropic', 'openai'], + sort: 'price', + }), + }), + }), + ); + }); + + it('should not add provider to modelKwargs when providerRouting is empty', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514'; + if (paramName === 'options') return { providerRouting: {} }; + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + const callArgs = MockedChatOpenAI.mock.calls[0][0]; + expect(callArgs?.modelKwargs).toBeUndefined(); + }); + }); }); describe('fetch wrapper (empty tool call arguments fix)', () => {