From 683fd112594f91b5d90bba6720f0e1081977158a Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Fri, 16 Oct 2026 12:43:58 +0000 Subject: [PATCH] fix(core): Pass reasoning effort on open router model node (#41179) --- .../LmChatOpenRouter/LmChatOpenRouter.node.ts | 77 +++++++++++++------ .../test/LmChatOpenRouter.test.ts | 74 ++++++++++++++++++ 2 files changed, 129 insertions(+), 22 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 2fd5c4f64b5..bc0189d870f 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts @@ -199,6 +199,36 @@ export class LmChatOpenRouter implements INodeType { type: 'collection', default: {}, options: [ + { + displayName: 'Reasoning Effort', + name: 'reasoningEffort', + type: 'options', + default: 'medium', + description: + 'Controls the amount of reasoning effort the model will use to generate a response. Higher values may result in more accurate and detailed responses, but may also take longer to generate.', + options: [ + { + name: 'Extra High', + value: 'xhigh', + }, + { + name: 'High', + value: 'high', + }, + { + name: 'Low', + value: 'low', + }, + { + name: 'Medium', + value: 'medium', + }, + { + name: 'Minimal', + value: 'minimal', + }, + ], + }, { displayName: 'Frequency Penalty', name: 'frequencyPenalty', @@ -375,6 +405,7 @@ export class LmChatOpenRouter implements INodeType { const modelName = this.getNodeParameter('model', itemIndex) as string; const options = this.getNodeParameter('options', itemIndex, {}) as { + reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; frequencyPenalty?: number; maxTokens?: number; maxRetries: number; @@ -411,50 +442,52 @@ export class LmChatOpenRouter implements INodeType { }, }; + const { reasoningEffort, providerRouting, responseFormat, ...modelOptions } = options; // Build provider routing object const provider: Record = {}; - if (options.providerRouting) { - const routing = options.providerRouting; - - if (routing.order) { - provider.order = routing.order + if (providerRouting) { + if (providerRouting.order) { + provider.order = providerRouting.order .split(',') .map((p) => p.trim()) .filter((p) => p); } - if (routing.allowFallbacks !== undefined) { - provider.allow_fallbacks = routing.allowFallbacks; + if (providerRouting.allowFallbacks !== undefined) { + provider.allow_fallbacks = providerRouting.allowFallbacks; } - if (routing.requireParameters !== undefined) { - provider.require_parameters = routing.requireParameters; + if (providerRouting.requireParameters !== undefined) { + provider.require_parameters = providerRouting.requireParameters; } - if (routing.dataCollection) { - provider.data_collection = routing.dataCollection; + if (providerRouting.dataCollection) { + provider.data_collection = providerRouting.dataCollection; } - if (routing.zdr !== undefined) { - provider.zdr = routing.zdr; + if (providerRouting.zdr !== undefined) { + provider.zdr = providerRouting.zdr; } - if (routing.only) { - provider.only = routing.only + if (providerRouting.only) { + provider.only = providerRouting.only .split(',') .map((p) => p.trim()) .filter((p) => p); } - if (routing.ignore) { - provider.ignore = routing.ignore + if (providerRouting.ignore) { + provider.ignore = providerRouting.ignore .split(',') .map((p) => p.trim()) .filter((p) => p); } - if (routing.sort) { - provider.sort = routing.sort; + if (providerRouting.sort) { + provider.sort = providerRouting.sort; } } // Build modelKwargs const modelKwargs: Record = {}; - if (options.responseFormat) { - modelKwargs.response_format = { type: options.responseFormat }; + if (reasoningEffort) { + modelKwargs.reasoning = { effort: reasoningEffort }; + } + if (responseFormat) { + modelKwargs.response_format = { type: responseFormat }; } if (Object.keys(provider).length > 0) { modelKwargs.provider = provider; @@ -463,7 +496,7 @@ export class LmChatOpenRouter implements INodeType { const model = new ChatOpenAI({ apiKey: credentials.apiKey, model: modelName, - ...options, + ...modelOptions, timeout, maxRetries: options.maxRetries ?? 2, configuration, 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 d482f25c777..c9a38daf87b 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 @@ -281,6 +281,80 @@ describe('LmChatOpenRouter', () => { expect(callArgs?.modelKwargs).toBeUndefined(); }); }); + + describe('reasoning effort', () => { + const setupWithOptions = (model: string, options: Record) => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return model; + if (paramName === 'options') return options; + return undefined; + }); + return ctx; + }; + + it.each(['minimal', 'low', 'medium', 'high', 'xhigh'])( + 'should set reasoning.effort to %s in modelKwargs', + async (effort) => { + const ctx = setupWithOptions('openai/gpt-6-sol', { reasoningEffort: effort }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: { reasoning: { effort } }, + }), + ); + }, + ); + + it('should not pass reasoningEffort as a ChatOpenAI field', async () => { + const ctx = setupWithOptions('openai/gpt-6-sol', { reasoningEffort: 'high' }); + + await node.supplyData.call(ctx, 0); + + const callArgs = MockedChatOpenAI.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('reasoningEffort'); + expect(callArgs).not.toHaveProperty('reasoning'); + }); + + it('should include reasoning, response format and provider routing together', async () => { + const ctx = setupWithOptions('openai/gpt-6-sol', { + reasoningEffort: 'low', + responseFormat: 'json_object', + providerRouting: { zdr: true }, + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: { + reasoning: { effort: 'low' }, + response_format: { type: 'json_object' }, + provider: { zdr: true }, + }, + }), + ); + }); + + it.each(['openai/gpt-6-sol', 'openai/gpt-5', 'anthropic/claude-sonnet-4-20250514'])( + 'should serialize reasoning in the OpenRouter request for %s', + async (model) => { + const ctx = setupWithOptions(model, { reasoningEffort: 'high' }); + await node.supplyData.call(ctx, 0); + + // Feed the node's constructor args into the real ChatOpenAI to inspect the request body + const actual = + await vi.importActual('@langchain/openai'); + const realModel = new actual.ChatOpenAI(MockedChatOpenAI.mock.calls[0][0]); + const params = realModel.invocationParams({}); + + expect(params).toMatchObject({ model, reasoning: { effort: 'high' } }); + expect(params).not.toHaveProperty('reasoning_effort'); + }, + ); + }); }); describe('fetch wrapper (empty tool call arguments fix)', () => {