fix(anthropic+bedrock): adaptive thinking + summarized display for claude-opus-4-7+ (#2471)

* fix(anthropic): use thinking.type=adaptive for claude-opus-4-7+

claude-opus-4-7 rejects the legacy thinking shape with a 400:
  `thinking.type.enabled` is not supported for this model.
  Use `thinking.type.adaptive` and `output_config.effort`.

Detect opus-4 minor version in getModelInfo and emit
{ type: "adaptive" } for >=7, keeping { type: "enabled", budget_tokens }
for opus-4-6 and earlier, sonnet-4, and 3-7-sonnet.

Bumps @langchain/anthropic ^1.0.0 -> ^1.3.29 to pick up the adaptive
ThinkingConfigParam variant and an updated tool-schema typing that lets
BedrockChatModel.convertTools drop its now-redundant Record cast.

Fixes #2361. Replaces stale PR #2362.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bedrock): use thinking.type=adaptive for claude-opus-4-7+

Same 400 from claude-opus-4-7+ as the direct Anthropic path (fixed in
the previous commit), but the Bedrock provider has its own payload
builder in BedrockChatModel.buildRequestBody that was unaffected.

Add the same minor-version detection inside that payload builder.
Uses an unanchored regex /claude-opus-4-(\d+)/ because Bedrock model
IDs carry a provider/profile prefix (e.g.
"global.anthropic.claude-opus-4-7-20260115-v1:0").

Fixes #2384.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: use real sonnet model IDs

claude-sonnet-4-7 doesn't exist; tests were asserting against a
fabricated ID. Switch the non-opus negative cases to a real
claude-sonnet-4-5 (and keep the existing claude-3-7-sonnet test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): set output_config.display=summarized for claude-opus-4-7+

Adaptive thinking on Opus 4.7+ alone restores the request shape but
leaves the UI empty — Anthropic flipped the default for
output_config.display from "summarized" to "omitted" on this model
line, so the API stops emitting visible thinking content blocks.
Pre-4.7 models still default to "summarized" server-side.

Set display: "summarized" alongside thinking: { type: "adaptive" } on
both the direct Anthropic and Bedrock paths so opus-4-7 renders
thinking summaries the same way sonnet 4.6 does today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): nest display=summarized inside thinking config

The Anthropic API places `display: 'summarized' | 'omitted'` on the
thinking config itself (ThinkingConfigAdaptive / ThinkingConfigEnabled),
not on a top-level `output_config`. The previous commit set
`outputConfig: { display: "summarized" }` on the ChatAnthropic config and
`output_config: { display: "summarized" }` on the Bedrock request body,
which broke the TS build (the SDK's OutputConfig only exposes
effort/format) and would not have produced the intended server behavior
for the adaptive thinking summarization toggle.

- chatModelManager.ts: emit `thinking: { type: "adaptive", display: "summarized" }`
- BedrockChatModel.ts: same, on the request payload
- BedrockChatModel.test.ts: update assertions to the new shape

* fix(thinking): constrain Opus minor regex so dated snapshots don't match

The previous `^claude-opus-4-(\d+)` (Anthropic) and unanchored
`claude-opus-4-(\d+)` (Bedrock) regex captured the date portion of
snapshot IDs like `claude-opus-4-20250514` as if it were the minor
version, so `usesAdaptiveThinking` evaluated as `true` (20250514 >= 7)
for pre-4.7 Opus models. That would send `thinking: { type: "adaptive" }`
for the legacy 4.0 snapshot and could trigger 400s on REASONING-enabled
runs.

Constrain the minor capture to 1-2 digits followed by `-`/`.` or end of
string. Real Anthropic minor versions are single/double digit; dated
snapshots have an 8-digit date that this regex now rejects.

- src/utils.ts: anchored fix for the Anthropic path
- src/LLMProviders/BedrockChatModel.ts: unanchored fix for Bedrock IDs
- regression tests for dated 4.0/4.1 snapshots in both src/utils.test.ts
  and src/LLMProviders/BedrockChatModel.test.ts

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-05-15 17:29:36 -07:00 committed by GitHub
parent 475d5b9c18
commit 3f12c14444
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 187 additions and 89 deletions

101
package-lock.json generated
View file

@ -12,7 +12,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@langchain/anthropic": "^1.0.0",
"@langchain/anthropic": "^1.3.29",
"@langchain/classic": "^1.0.9",
"@langchain/core": "^1.1.29",
"@langchain/deepseek": "^1.0.0",
@ -123,6 +123,26 @@
"node": ">=6.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.91.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@ -2323,38 +2343,19 @@
"dev": true
},
"node_modules/@langchain/anthropic": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.0.0.tgz",
"integrity": "sha512-Lud/FrkFmXMYW5R9y0FC+RGdgjBBVQ2JAnG3A8E1I4+sqv5JgJttw3HKRpFkyBUSyacs6LMfSn5dbJ6TT9nMiQ==",
"version": "1.3.29",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.29.tgz",
"integrity": "sha512-ep1qBIcV07bajsg3fDqMd39rYwoRLOEK/6lk+MCxlm1YB5SRoKKJAZANrblQ/4RYhZJnxf95c6BSQu8VoNbVAQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.65.0"
"@anthropic-ai/sdk": "^0.91.1",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.0.0"
}
},
"node_modules/@langchain/anthropic/node_modules/@anthropic-ai/sdk": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.65.0.tgz",
"integrity": "sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
"@langchain/core": "^1.1.45"
}
},
"node_modules/@langchain/classic": {
@ -2411,49 +2412,23 @@
}
},
"node_modules/@langchain/core": {
"version": "1.1.29",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.29.tgz",
"integrity": "sha512-BPoegTtIdZX4gl2kxcSXAlLrrJFl1cxeRsk9DM/wlIuvyPrFwjWqrEK5NwF5diDt5XSArhQxIFaifGAl4F7fgw==",
"version": "1.1.46",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.46.tgz",
"integrity": "sha512-i8rDC83BpItxChCw4Lf+6tAr+k+OUcbirc5ZkrhI9ywYWmvxegUljLGOGYvtJNTbEAIFkhYIODPE5QRqyjF6sA==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
"@standard-schema/spec": "^1.1.0",
"js-tiktoken": "^1.0.12",
"langsmith": ">=0.5.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"uuid": "^11.1.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@langchain/core/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@langchain/core/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/core/node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
@ -6169,6 +6144,12 @@
"@sinonjs/commons": "^2.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@ -8215,14 +8196,6 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",

View file

@ -76,7 +76,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@langchain/anthropic": "^1.0.0",
"@langchain/anthropic": "^1.3.29",
"@langchain/classic": "^1.0.9",
"@langchain/core": "^1.1.29",
"@langchain/deepseek": "^1.0.0",

View file

@ -22,7 +22,9 @@ type ImageContent = {
} | null;
type RequestBody = {
thinking?: { type: string; budget_tokens: number };
thinking?:
| { type: "enabled"; budget_tokens: number }
| { type: "adaptive"; display?: "summarized" | "omitted" };
temperature?: number;
anthropic_version?: string;
messages: Array<{
@ -77,13 +79,15 @@ const buildEventStreamChunk = (payload: string): string => {
return Buffer.from(buffer).toString("base64");
};
const createModel = (enableThinking = false): BedrockChatModel =>
const createModel = (
enableThinking = false,
modelId = "anthropic.claude-3-haiku-20240307-v1:0"
): BedrockChatModel =>
new BedrockChatModel({
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
modelId,
apiKey: "test-key",
endpoint: "https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke",
streamEndpoint:
"https://example.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/invoke-with-response-stream",
endpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke`,
streamEndpoint: `https://example.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`,
anthropicVersion: "bedrock-2023-05-31",
enableThinking,
fetchImplementation: jest.fn(),
@ -455,6 +459,68 @@ describe("BedrockChatModel streaming decode", () => {
expect(requestBody.temperature).toBe(1);
expect(requestBody.thinking).toBeDefined();
});
it("uses adaptive thinking with summarized display for claude-opus-4-7", () => {
const model = createModel(true, "anthropic.claude-opus-4-7-20260115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(requestBody.temperature).toBe(1);
});
it("uses adaptive thinking for opus-4-7 cross-region inference profiles", () => {
const model = createModel(true, "global.anthropic.claude-opus-4-7-20260115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "adaptive", display: "summarized" });
});
it("keeps legacy thinking for opus-4-6 and earlier", () => {
const model = createModel(true, "anthropic.claude-opus-4-6-20250115-v1:0");
const requestBody = asInternal(model).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]);
expect(requestBody.thinking).toEqual({ type: "enabled", budget_tokens: 2048 });
});
it("keeps legacy thinking for sonnet-4 and 3-7-sonnet", () => {
const sonnet45 = createModel(true, "anthropic.claude-sonnet-4-5-20250929-v1:0");
expect(
asInternal(sonnet45).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
const sonnet37 = createModel(true, "anthropic.claude-3-7-sonnet-20250219-v1:0");
expect(
asInternal(sonnet37).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
});
it("keeps legacy thinking for dated Opus 4.0 snapshot IDs", () => {
// anthropic.claude-opus-4-20250514-v1:0 is the dated snapshot of Opus 4.0, not 4.20250514.
const opus40 = createModel(true, "anthropic.claude-opus-4-20250514-v1:0");
expect(
asInternal(opus40).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
// anthropic.claude-opus-4-1-20250805-v1:0 is dated 4.1, not adaptive.
const opus41 = createModel(true, "anthropic.claude-opus-4-1-20250805-v1:0");
expect(
asInternal(opus41).buildRequestBody([
{ role: "user", content: "test", getType: () => "human" },
]).thinking
).toEqual({ type: "enabled", budget_tokens: 2048 });
});
});
describe("vision support", () => {

View file

@ -177,13 +177,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
return tools.map((tool) => {
let inputSchema: Record<string, unknown> = { type: "object", properties: {} };
if (tool.schema) {
// Use LangChain's schema conversion utilities. The result is cast via `unknown`
// because `toJsonSchema` returns a `JsonSchema7Type` union without an index
// signature, which TypeScript cannot directly assign to `Record<string, unknown>`.
const converted: unknown = isInteropZodSchema(tool.schema)
? toJsonSchema(tool.schema)
: tool.schema;
inputSchema = converted as Record<string, unknown>;
inputSchema = isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema;
}
return {
name: tool.name,
@ -1479,12 +1473,19 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
// Handle thinking mode for Claude models
// Only enable if user has explicitly enabled REASONING capability for this model
if (this.enableThinking) {
// Enable thinking mode for Claude models on Bedrock
// This allows the model to generate reasoning tokens
payload.thinking = {
type: "enabled",
budget_tokens: 2048,
};
// claude-opus-4-7+ rejects { type: "enabled", budget_tokens } with a 400 and requires
// { type: "adaptive" }. Unanchored match because Bedrock IDs include provider/profile
// prefixes (e.g. "global.anthropic.claude-opus-4-7-20260115-v1:0"). Constrain the minor
// to 1-2 digits followed by a delimiter so dated snapshot IDs like
// "claude-opus-4-20250514-v1:0" aren't misread as Opus 4.20250514.
const opusMinorMatch = this.modelName.match(/claude-opus-4-(\d{1,2})(?:[-.]|$)/);
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
// models default to "summarized" server-side.
payload.thinking = usesAdaptiveThinking
? { type: "adaptive", display: "summarized" }
: { type: "enabled", budget_tokens: 2048 };
// When thinking is enabled, temperature must be 1
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
payload.temperature = 1;

View file

@ -201,7 +201,7 @@ export default class ChatModelManager {
const modelName = customModel.name;
const modelInfo = getModelInfo(modelName);
const { isThinkingEnabled } = modelInfo;
const { isThinkingEnabled, usesAdaptiveThinking } = modelInfo;
const resolvedTemperature = this.getTemperatureForModel(modelInfo, customModel, settings);
const maxTokens = customModel.maxTokens ?? settings.maxTokens;
@ -248,10 +248,15 @@ export default class ChatModelManager {
fetch: customModel.enableCors ? safeFetch : undefined,
},
...(isThinkingEnabled && {
thinking: {
type: "enabled",
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
},
// Opus 4.7+ defaults thinking.display to "omitted" so thinking summaries
// never reach the UI; force "summarized" for the adaptive branch. Pre-4.7
// models default to "summarized" server-side and don't need this.
thinking: usesAdaptiveThinking
? { type: "adaptive" as const, display: "summarized" as const }
: {
type: "enabled" as const,
budget_tokens: ChatModelManager.ANTHROPIC_THINKING_BUDGET_TOKENS,
},
}),
},
[ChatModelProviders.AZURE_OPENAI]: await (async (): Promise<Record<string, unknown>> => {

View file

@ -4,6 +4,7 @@ import {
extractNoteFiles,
extractTemplateNoteFiles,
formatDateTime,
getModelInfo,
getNotesFromPath,
getNotesFromTags,
getUtf8ByteLength,
@ -941,3 +942,46 @@ describe("stringToFormattedDateTime", () => {
expect(result.epoch).toBeLessThanOrEqual(after);
});
});
describe("getModelInfo", () => {
it("flags claude-opus-4-7 as adaptive thinking", () => {
const info = getModelInfo("claude-opus-4-7");
expect(info.isThinkingEnabled).toBe(true);
expect(info.usesAdaptiveThinking).toBe(true);
});
it("flags claude-opus-4-8 and higher as adaptive thinking", () => {
expect(getModelInfo("claude-opus-4-8").usesAdaptiveThinking).toBe(true);
expect(getModelInfo("claude-opus-4-12").usesAdaptiveThinking).toBe(true);
});
it("keeps claude-opus-4-6 and earlier on legacy thinking", () => {
const six = getModelInfo("claude-opus-4-6");
expect(six.isThinkingEnabled).toBe(true);
expect(six.usesAdaptiveThinking).toBe(false);
const zero = getModelInfo("claude-opus-4-0");
expect(zero.isThinkingEnabled).toBe(true);
expect(zero.usesAdaptiveThinking).toBe(false);
});
it("does not affect other thinking-enabled families", () => {
expect(getModelInfo("claude-sonnet-4-5").usesAdaptiveThinking).toBe(false);
expect(getModelInfo("claude-3-7-sonnet-20250219").usesAdaptiveThinking).toBe(false);
});
it("does not match unversioned claude-opus-4 prefix", () => {
const bare = getModelInfo("claude-opus-4");
expect(bare.isThinkingEnabled).toBe(true);
expect(bare.usesAdaptiveThinking).toBe(false);
});
it("does not treat dated snapshot IDs as adaptive thinking minors", () => {
// claude-opus-4-20250514 is the dated snapshot of Opus 4.0, not Opus 4.20250514.
expect(getModelInfo("claude-opus-4-20250514").usesAdaptiveThinking).toBe(false);
// claude-opus-4-1-20250805 is dated 4.1.
expect(getModelInfo("claude-opus-4-1-20250805").usesAdaptiveThinking).toBe(false);
// Dated 4.7 still matches because the minor is delimited by "-".
expect(getModelInfo("claude-opus-4-7-20260115").usesAdaptiveThinking).toBe(true);
});
});

View file

@ -1063,6 +1063,7 @@ export interface ModelInfo {
isOSeries: boolean;
isGPT5: boolean;
isThinkingEnabled: boolean;
usesAdaptiveThinking: boolean;
}
export function getModelInfo(model: BaseChatModel | string): ModelInfo {
@ -1077,10 +1078,18 @@ export function getModelInfo(model: BaseChatModel | string): ModelInfo {
modelName.startsWith("claude-sonnet-4") ||
modelName.startsWith("claude-opus-4");
// claude-opus-4-7 and later reject the legacy { type: "enabled", budget_tokens } shape
// with a 400 and require { type: "adaptive" }. Detect by minor version on the opus-4 line.
// Constrain the minor to 1-2 digits followed by a delimiter or end-of-string so dated
// snapshot IDs (e.g. "claude-opus-4-20250514") aren't misread as Opus 4.20250514.
const opusMinorMatch = modelName.match(/^claude-opus-4-(\d{1,2})(?:[-.]|$)/);
const usesAdaptiveThinking = opusMinorMatch ? parseInt(opusMinorMatch[1], 10) >= 7 : false;
return {
isOSeries,
isGPT5,
isThinkingEnabled,
usesAdaptiveThinking,
};
}