From 90d875ce3e5a2a004a5a3d8f28ac4e9820b109f4 Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:23:49 +0200 Subject: [PATCH] fix(Anthropic Chat Model Node): Add adaptive thinking mode for Claude Opus 4.7+ (#29467) --- .../LMChatAnthropic/LmChatAnthropic.node.ts | 218 ++++++++++++++-- .../test/LmChatAnthropic.test.ts | 245 +++++++++++++++++- .../nodes/llms/test/LmChatAnthropic.test.ts | 2 +- 3 files changed, 431 insertions(+), 34 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts index 06562305153..cc5f8242e99 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts @@ -1,4 +1,4 @@ -import { ChatAnthropic } from '@langchain/anthropic'; +import { ChatAnthropic, type ChatAnthropicInput } from '@langchain/anthropic'; import type { LLMResult } from '@langchain/core/outputs'; import { getProxyAgent, @@ -21,7 +21,7 @@ import { searchModels } from './methods/searchModels'; const ANTHROPIC_MODEL_BUILDER_HINT = { message: - 'Default to claude-sonnet-4-6 (latest Sonnet); use claude-opus-4-7 when the user needs the most capable model. Never use Claude Sonnet 4.5, Claude 3.x, Claude 2, or LEGACY options — those are superseded and are not valid choices.', + 'Default to claude-sonnet-4-6 (latest Sonnet); use claude-opus-4-7 when the user needs the most capable model. Never use Claude Sonnet 4.5, Claude 3.x, Claude 2, or LEGACY options — those are superseded and are not valid choices. When extended thinking is needed on Opus 4.7+, set Thinking Mode to Adaptive and choose an Effort level. The legacy Manual thinking mode is rejected by Opus 4.7.', }; const modelField: INodeProperties = { @@ -92,8 +92,8 @@ export class LmChatAnthropic implements INodeType { name: 'lmChatAnthropic', icon: 'file:anthropic.svg', group: ['transform'], - version: [1, 1.1, 1.2, 1.3, 1.4], - defaultVersion: 1.4, + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5], + defaultVersion: 1.5, description: 'Language Model Anthropic', defaults: { name: 'Anthropic Chat Model', @@ -225,7 +225,44 @@ export class LmChatAnthropic implements INodeType { 'The model. Choose from the list, or specify an ID. Learn more.', displayOptions: { show: { - '@version': [{ _cnd: { gte: 1.4 } }], + '@version': [1.4], + }, + }, + }, + { + displayName: 'Model', + name: 'model', + type: 'resourceLocator', + default: { + mode: 'list', + value: 'claude-sonnet-4-6', + cachedResultName: 'Claude Sonnet 4.6', + }, + builderHint: ANTHROPIC_MODEL_BUILDER_HINT, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a model...', + typeOptions: { + searchListMethod: 'searchModels', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'Claude Sonnet', + }, + ], + description: + 'The model. Choose from the list, or specify an ID. Learn more.', + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.5 } }], }, }, }, @@ -255,6 +292,7 @@ export class LmChatAnthropic implements INodeType { displayOptions: { hide: { thinking: [true], + thinkingMode: ['adaptive', 'manual'], }, }, }, @@ -269,6 +307,7 @@ export class LmChatAnthropic implements INodeType { displayOptions: { hide: { thinking: [true], + thinkingMode: ['adaptive', 'manual'], }, }, }, @@ -283,6 +322,7 @@ export class LmChatAnthropic implements INodeType { displayOptions: { hide: { thinking: [true], + thinkingMode: ['adaptive', 'manual'], }, }, }, @@ -292,6 +332,11 @@ export class LmChatAnthropic implements INodeType { type: 'boolean', default: false, description: 'Whether to enable thinking mode for the model', + displayOptions: { + show: { + '@version': [{ _cnd: { lte: 1.4 } }], + }, + }, }, { displayName: 'Thinking Budget (Tokens)', @@ -301,10 +346,94 @@ export class LmChatAnthropic implements INodeType { description: 'The maximum number of tokens to use for thinking', displayOptions: { show: { + '@version': [{ _cnd: { lte: 1.4 } }], thinking: [true], }, }, }, + { + displayName: 'Thinking Mode', + name: 'thinkingMode', + type: 'options', + default: 'disabled', + description: 'How extended thinking should be configured for the model', + options: [ + { + name: 'Disabled', + value: 'disabled', + description: 'No extended thinking', + }, + { + name: 'Adaptive (Recommended)', + value: 'adaptive', + description: 'Claude decides how much to think; control with Effort', + }, + { + name: 'Manual (Deprecated)', + value: 'manual', + description: 'Legacy fixed-budget mode; rejected by Opus 4.7+', + }, + ], + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.5 } }], + }, + }, + }, + { + displayName: 'Effort', + name: 'effort', + type: 'options', + default: 'medium', + description: 'Effort level for adaptive thinking', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { name: 'Low', value: 'low' }, + { name: 'Medium', value: 'medium' }, + { name: 'High', value: 'high' }, + { name: 'X-High', value: 'xhigh' }, + { name: 'Max', value: 'max' }, + ], + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.5 } }], + thinkingMode: ['adaptive'], + '/model.value': [{ _cnd: { includes: 'opus' } }], + }, + }, + }, + { + displayName: 'Effort', + name: 'effort', + type: 'options', + default: 'medium', + description: 'Effort level for adaptive thinking', + options: [ + { name: 'Low', value: 'low' }, + { name: 'Medium', value: 'medium' }, + { name: 'High', value: 'high' }, + ], + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.5 } }], + thinkingMode: ['adaptive'], + '/model.value': [{ _cnd: { regex: '^(?!.*opus).*' } }], + }, + }, + }, + { + displayName: 'Thinking Budget (Tokens)', + name: 'thinkingBudget', + type: 'number', + default: MIN_THINKING_BUDGET, + description: 'Maximum tokens used for thinking. Manual mode is rejected by Opus 4.7+.', + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.5 } }], + thinkingMode: ['manual'], + }, + }, + }, ], }, ], @@ -333,30 +462,42 @@ export class LmChatAnthropic implements INodeType { const options = this.getNodeParameter('options', itemIndex, {}) as { maxTokensToSample?: number; - temperature: number; + temperature?: number; topK?: number; topP?: number; thinking?: boolean; thinkingBudget?: number; - }; - let invocationKwargs = {}; - - const tokensUsageParser = (result: LLMResult) => { - const usage = (result?.llmOutput?.usage as { - input_tokens: number; - output_tokens: number; - }) ?? { - input_tokens: 0, - output_tokens: 0, - }; - return { - completionTokens: usage.output_tokens, - promptTokens: usage.input_tokens, - totalTokens: usage.input_tokens + usage.output_tokens, - }; + thinkingMode?: 'disabled' | 'adaptive' | 'manual'; + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; }; - if (options.thinking) { + const isOpus47Model = modelName.startsWith('claude-opus-4-7'); + const thinkingMode: 'disabled' | 'adaptive' | 'manual' = + version >= 1.5 + ? (options.thinkingMode ?? 'disabled') + : options.thinking + ? 'manual' + : 'disabled'; + + if (thinkingMode === 'manual' && isOpus47Model) { + throw new NodeOperationError( + this.getNode(), + `Manual thinking mode is not supported on "${modelName}". Use Thinking Mode = Adaptive (with Effort) instead.`, + { itemIndex }, + ); + } + + let invocationKwargs: Record = {}; + if (thinkingMode === 'adaptive') { + invocationKwargs = { + thinking: { type: 'adaptive' }, + output_config: { effort: options.effort ?? 'medium' }, + max_tokens: options.maxTokensToSample ?? DEFAULT_MAX_TOKENS, + top_k: undefined, + top_p: undefined, + temperature: undefined, + }; + } else if (thinkingMode === 'manual') { invocationKwargs = { thinking: { type: 'enabled', @@ -376,6 +517,21 @@ export class LmChatAnthropic implements INodeType { }; } + const tokensUsageParser = (result: LLMResult) => { + const usage = (result?.llmOutput?.usage as { + input_tokens: number; + output_tokens: number; + }) ?? { + input_tokens: 0, + output_tokens: 0, + }; + return { + completionTokens: usage.output_tokens, + promptTokens: usage.input_tokens, + totalTokens: usage.input_tokens + usage.output_tokens, + }; + }; + const clientOptions: { fetchOptions?: { dispatcher: ReturnType }; defaultHeaders?: Record; @@ -412,19 +568,25 @@ export class LmChatAnthropic implements INodeType { } : undefined; - const model = new ChatAnthropic({ + const chatAnthropicParams: ChatAnthropicInput = { anthropicApiKey: credentials.apiKey, model: modelName, anthropicApiUrl: baseURL, maxTokens: options.maxTokensToSample, - temperature: options.temperature, - topK: options.topK, - topP: options.topP, callbacks: [new N8nLlmTracing(this, { tokensUsageParser })], onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, gatewayErrorHandler), invocationKwargs, clientOptions, - }); + }; + + // Opus 4.7 rejects temperature/topK/topP at the SDK layer regardless of thinking mode + if (!isOpus47Model) { + chatAnthropicParams.temperature = options.temperature; + chatAnthropicParams.topK = options.topK; + chatAnthropicParams.topP = options.topP; + } + + const model = new ChatAnthropic(chatAnthropicParams); // Some Anthropic models do not support Langchain default of -1 for topP so we need to unset it if (options.topP === undefined) { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts index 917a7818981..62ca7cdb5a1 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts @@ -4,7 +4,7 @@ import { ChatAnthropic } from '@langchain/anthropic'; import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; -import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; +import type { INode, INodeProperties, ISupplyDataFunctions } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; import { LmChatAnthropic } from '../LmChatAnthropic.node'; @@ -79,7 +79,7 @@ describe('LmChatAnthropic', () => { displayName: 'Anthropic Chat Model', name: 'lmChatAnthropic', group: ['transform'], - version: [1, 1.1, 1.2, 1.3, 1.4], + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5], description: 'Language Model Anthropic', }); }); @@ -570,13 +570,12 @@ describe('LmChatAnthropic', () => { }); }); - it('should have Claude Sonnet 4.6 as default for v1.4+ resource locator', () => { + it('should have Claude Sonnet 4.6 as default for v1.4 resource locator', () => { const v14ModelField = lmChatAnthropic.description.properties.find( (p) => p.name === 'model' && p.type === 'resourceLocator' && - (p.displayOptions?.show?.['@version']?.[0] as { _cnd?: { gte?: number } })?._cnd?.gte === - 1.4, + p.displayOptions?.show?.['@version']?.[0] === 1.4, ); expect(v14ModelField).toBeDefined(); @@ -588,6 +587,242 @@ describe('LmChatAnthropic', () => { }); }); + describe('thinking modes (v1.5)', () => { + it('should not set thinking-related invocationKwargs when thinkingMode is disabled', async () => { + const mockContext = setupMockContext({ typeVersion: 1.5 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-6'; + if (paramName === 'options') + return { thinkingMode: 'disabled', temperature: 0.5, topK: 10, topP: 0.8 }; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-sonnet-4-6', + temperature: 0.5, + topK: 10, + topP: 0.8, + invocationKwargs: {}, + }), + ); + }); + + it('should configure adaptive thinking with default effort (medium)', async () => { + const mockContext = setupMockContext({ typeVersion: 1.5 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-6'; + if (paramName === 'options') return { thinkingMode: 'adaptive' }; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-sonnet-4-6', + invocationKwargs: { + thinking: { type: 'adaptive' }, + output_config: { effort: 'medium' }, + max_tokens: 4096, + top_k: undefined, + top_p: undefined, + temperature: undefined, + }, + }), + ); + }); + + it.each(['low', 'medium', 'high', 'xhigh', 'max'] as const)( + 'should forward effort=%s for adaptive mode', + async (effort) => { + const mockContext = setupMockContext({ typeVersion: 1.5 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-opus-4-7-20251101'; + if (paramName === 'options') return { thinkingMode: 'adaptive', effort }; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + invocationKwargs: expect.objectContaining({ + thinking: { type: 'adaptive' }, + output_config: { effort }, + }), + }), + ); + }, + ); + + it('should keep legacy enabled+budget payload for manual thinkingMode on Sonnet 4.6', async () => { + const mockContext = setupMockContext({ typeVersion: 1.5 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-6'; + if (paramName === 'options') + return { thinkingMode: 'manual', thinkingBudget: 2048, maxTokensToSample: 4096 }; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-sonnet-4-6', + invocationKwargs: { + thinking: { type: 'enabled', budget_tokens: 2048 }, + max_tokens: 4096, + top_k: undefined, + top_p: undefined, + temperature: undefined, + }, + }), + ); + }); + + it('should strip temperature/topK/topP from constructor when model is Opus 4.7 (disabled mode)', async () => { + const mockContext = setupMockContext({ typeVersion: 1.5 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-opus-4-7-20251101'; + if (paramName === 'options') + return { thinkingMode: 'disabled', temperature: 0.5, topK: 40, topP: 0.9 }; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + const callArgs = MockedChatAnthropic.mock.calls[0][0]!; + expect(callArgs.model).toBe('claude-opus-4-7-20251101'); + expect(callArgs).not.toHaveProperty('temperature'); + expect(callArgs).not.toHaveProperty('topK'); + expect(callArgs).not.toHaveProperty('topP'); + }); + + it('should throw NodeOperationError when manual mode is selected on Opus 4.7', async () => { + const mockContext = setupMockContext({ typeVersion: 1.5 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-opus-4-7-20251101'; + if (paramName === 'options') return { thinkingMode: 'manual', thinkingBudget: 2048 }; + return undefined; + }); + + await expect(lmChatAnthropic.supplyData.call(mockContext, 0)).rejects.toThrow( + NodeOperationError, + ); + expect(MockedChatAnthropic).not.toHaveBeenCalled(); + }); + + it('should still emit legacy thinking payload when thinking=true on v1.4', async () => { + const mockContext = setupMockContext({ typeVersion: 1.4 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-6'; + if (paramName === 'options') + return { thinking: true, thinkingBudget: 1500, maxTokensToSample: 4096 }; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + invocationKwargs: { + thinking: { type: 'enabled', budget_tokens: 1500 }, + max_tokens: 4096, + top_k: undefined, + top_p: undefined, + temperature: undefined, + }, + }), + ); + }); + + it('should emit empty invocationKwargs when thinking=false on v1.4', async () => { + const mockContext = setupMockContext({ typeVersion: 1.4 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-6'; + if (paramName === 'options') return { thinking: false }; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ invocationKwargs: {} }), + ); + }); + + it('should describe v1.5 model field, thinkingMode, and gated effort fields', () => { + const properties = lmChatAnthropic.description.properties; + + const v15ModelField = properties.find( + (p) => + p.name === 'model' && + p.type === 'resourceLocator' && + (p.displayOptions?.show?.['@version']?.[0] as { _cnd?: { gte?: number } })?._cnd?.gte === + 1.5, + ); + expect(v15ModelField).toBeDefined(); + expect(v15ModelField!.default).toEqual({ + mode: 'list', + value: 'claude-sonnet-4-6', + cachedResultName: 'Claude Sonnet 4.6', + }); + + const optionsField = properties.find((p) => p.name === 'options' && p.type === 'collection'); + expect(optionsField).toBeDefined(); + + const innerOptions = (optionsField as { options: INodeProperties[] }).options; + + const thinkingMode = innerOptions.find((o) => o.name === 'thinkingMode'); + expect(thinkingMode).toBeDefined(); + expect(thinkingMode!.type).toBe('options'); + const modeValues = (thinkingMode as { options: Array<{ value: string }> }).options.map( + (o) => o.value, + ); + expect(modeValues).toEqual(['disabled', 'adaptive', 'manual']); + + const effortFields = innerOptions.filter((o) => o.name === 'effort'); + expect(effortFields).toHaveLength(2); + + const opusEffort = effortFields.find((f) => { + const cnd = ( + f.displayOptions?.show?.['/model.value']?.[0] as { + _cnd?: { includes?: string }; + } + )?._cnd; + return cnd?.includes === 'opus'; + }); + expect(opusEffort).toBeDefined(); + expect( + (opusEffort as { options: Array<{ value: string }> }).options.map((o) => o.value), + ).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); + + const nonOpusEffort = effortFields.find((f) => { + const cnd = ( + f.displayOptions?.show?.['/model.value']?.[0] as { + _cnd?: { regex?: string }; + } + )?._cnd; + return typeof cnd?.regex === 'string'; + }); + expect(nonOpusEffort).toBeDefined(); + expect( + (nonOpusEffort as { options: Array<{ value: string }> }).options.map((o) => o.value), + ).toEqual(['low', 'medium', 'high']); + }); + }); + describe('methods', () => { it('should have searchModels method', () => { expect(lmChatAnthropic.methods).toEqual({ diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts index cfe3b22e054..deb44109931 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts @@ -64,7 +64,7 @@ describe('LmChatAnthropic', () => { displayName: 'Anthropic Chat Model', name: 'lmChatAnthropic', group: ['transform'], - version: [1, 1.1, 1.2, 1.3, 1.4], + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5], description: 'Language Model Anthropic', }); });