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',
});
});