fix(Anthropic Chat Model Node): Add adaptive thinking mode for Claude Opus 4.7+ (#29467)

This commit is contained in:
Benjamin Schroth
2026-04-30 13:23:49 +00:00
committed by GitHub
parent a7ef7416b1
commit 90d875ce3e
3 changed files with 431 additions and 34 deletions
@@ -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. <a href="https://docs.anthropic.com/claude/docs/models-overview">Learn more</a>.',
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. <a href="https://docs.anthropic.com/claude/docs/models-overview">Learn more</a>.',
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<string, unknown> = {};
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<typeof getProxyAgent> };
defaultHeaders?: Record<string, string>;
@@ -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) {
@@ -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({
@@ -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',
});
});