From d1c6cb1e0f8bf7051c33d66588999b347ae08b04 Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen <8409947+markusylisiurunen@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:57:19 +0300 Subject: [PATCH] fix(ai): Fix a configuration bug with Opus 4.7 adaptive thinking (#3286) --- package-lock.json | 10 +-- packages/ai/package.json | 4 +- packages/ai/src/models.ts | 9 ++- packages/ai/src/providers/amazon-bedrock.ts | 14 +++- packages/ai/src/providers/anthropic.ts | 21 +++-- .../ai/test/anthropic-opus-4-7-smoke.test.ts | 72 +++++++++++++++++ .../test/anthropic-thinking-disable.test.ts | 29 ++++++- .../ai/test/bedrock-thinking-payload.test.ts | 78 +++++++++++++++++++ packages/ai/test/supports-xhigh.test.ts | 6 ++ 9 files changed, 223 insertions(+), 20 deletions(-) create mode 100644 packages/ai/test/anthropic-opus-4-7-smoke.test.ts create mode 100644 packages/ai/test/bedrock-thinking-payload.test.ts diff --git a/package-lock.json b/package-lock.json index fd3ebd48..2462a1ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,9 +64,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.73.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", - "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "version": "0.90.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz", + "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -8714,8 +8714,8 @@ "version": "0.67.4", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.73.0", - "@aws-sdk/client-bedrock-runtime": "^3.983.0", + "@anthropic-ai/sdk": "^0.90.0", + "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.14.1", "@sinclair/typebox": "^0.34.41", diff --git a/packages/ai/package.json b/packages/ai/package.json index 5d7434f9..f31cc5bd 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -72,8 +72,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@anthropic-ai/sdk": "^0.73.0", - "@aws-sdk/client-bedrock-runtime": "^3.983.0", + "@anthropic-ai/sdk": "^0.90.0", + "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.14.1", "@sinclair/typebox": "^0.34.41", diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 101c83f2..8944d07e 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -50,14 +50,19 @@ export function calculateCost(model: Model, usage: Usage * * Supported today: * - GPT-5.2 / GPT-5.3 / GPT-5.4 model families - * - Opus 4.6 models (xhigh maps to adaptive effort "max" on Anthropic-compatible providers) + * - Opus 4.6+ models (xhigh maps to adaptive effort "max" on Anthropic-compatible providers) */ export function supportsXhigh(model: Model): boolean { if (model.id.includes("gpt-5.2") || model.id.includes("gpt-5.3") || model.id.includes("gpt-5.4")) { return true; } - if (model.id.includes("opus-4-6") || model.id.includes("opus-4.6")) { + if ( + model.id.includes("opus-4-6") || + model.id.includes("opus-4.6") || + model.id.includes("opus-4-7") || + model.id.includes("opus-4.7") + ) { return true; } diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index a0aa55c3..00333f0c 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -423,12 +423,14 @@ function handleContentBlockStop( } /** - * Check if the model supports adaptive thinking (Opus 4.6 and Sonnet 4.6). + * Check if the model supports adaptive thinking (Opus 4.6+, Sonnet 4.6). */ function supportsAdaptiveThinking(modelId: string): boolean { return ( modelId.includes("opus-4-6") || modelId.includes("opus-4.6") || + modelId.includes("opus-4-7") || + modelId.includes("opus-4.7") || modelId.includes("sonnet-4-6") || modelId.includes("sonnet-4.6") ); @@ -437,7 +439,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { function mapThinkingLevelToEffort( level: SimpleStreamOptions["reasoning"], modelId: string, -): "low" | "medium" | "high" | "max" { +): "low" | "medium" | "high" | "xhigh" | "max" { switch (level) { case "minimal": case "low": @@ -447,7 +449,13 @@ function mapThinkingLevelToEffort( case "high": return "high"; case "xhigh": - return modelId.includes("opus-4-6") || modelId.includes("opus-4.6") ? "max" : "high"; + if (modelId.includes("opus-4-6") || modelId.includes("opus-4.6")) { + return "max"; + } + if (modelId.includes("opus-4-7") || modelId.includes("opus-4.7")) { + return "xhigh"; + } + return "high"; default: return "high"; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 53d29e45..1741b400 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -153,7 +153,7 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]): return blocks; } -export type AnthropicEffort = "low" | "medium" | "high" | "max"; +export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; export interface AnthropicOptions extends StreamOptions { /** @@ -168,9 +168,10 @@ export interface AnthropicOptions extends StreamOptions { */ thinkingBudgetTokens?: number; /** - * Effort level for adaptive thinking (Opus 4.6 and Sonnet 4.6). + * Effort level for adaptive thinking (Opus 4.6+ and Sonnet 4.6). * Controls how much thinking Claude allocates: * - "max": Always thinks with no constraints (Opus 4.6 only) + * - "xhigh": Highest reasoning level (Opus 4.7) * - "high": Always thinks, deep reasoning (default) * - "medium": Moderate thinking, may skip for simple queries * - "low": Minimal thinking, skips for simple tasks @@ -448,13 +449,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti }; /** - * Check if a model supports adaptive thinking (Opus 4.6 and Sonnet 4.6) + * Check if a model supports adaptive thinking (Opus 4.6+, Sonnet 4.6) */ function supportsAdaptiveThinking(modelId: string): boolean { - // Opus 4.6 and Sonnet 4.6 model IDs (with or without date suffix) + // Adaptive-thinking model IDs (with or without date suffix) return ( modelId.includes("opus-4-6") || modelId.includes("opus-4.6") || + modelId.includes("opus-4-7") || + modelId.includes("opus-4.7") || modelId.includes("sonnet-4-6") || modelId.includes("sonnet-4.6") ); @@ -462,7 +465,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { /** * Map ThinkingLevel to Anthropic effort levels for adaptive thinking. - * Note: effort "max" is only valid on Opus 4.6. + * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh". */ function mapThinkingLevelToEffort(level: SimpleStreamOptions["reasoning"], modelId: string): AnthropicEffort { switch (level) { @@ -475,7 +478,13 @@ function mapThinkingLevelToEffort(level: SimpleStreamOptions["reasoning"], model case "high": return "high"; case "xhigh": - return modelId.includes("opus-4-6") || modelId.includes("opus-4.6") ? "max" : "high"; + if (modelId.includes("opus-4-6") || modelId.includes("opus-4.6")) { + return "max"; + } + if (modelId.includes("opus-4-7") || modelId.includes("opus-4.7")) { + return "xhigh"; + } + return "high"; default: return "high"; } diff --git a/packages/ai/test/anthropic-opus-4-7-smoke.test.ts b/packages/ai/test/anthropic-opus-4-7-smoke.test.ts new file mode 100644 index 00000000..29626d1f --- /dev/null +++ b/packages/ai/test/anthropic-opus-4-7-smoke.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { streamSimple } from "../src/stream.js"; +import type { Context } from "../src/types.js"; + +interface AnthropicThinkingPayload { + thinking?: { type: string }; + output_config?: { effort?: string }; +} + +function makeContext(): Context { + return { + systemPrompt: "You are a precise assistant. Follow the user's instructions exactly.", + messages: [ + { + role: "user", + content: + "Compute 48291 * 7317 and 90844 - 17729, add the results, and determine whether the sum is divisible by 11. Reply with exactly this format and nothing else: sum=; divisibleBy11=", + timestamp: Date.now(), + }, + ], + }; +} + +describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () => { + it("streams Claude Opus 4.7 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => { + const model = getModel("anthropic", "claude-opus-4-7"); + let capturedPayload: AnthropicThinkingPayload | undefined; + const s = streamSimple(model, makeContext(), { + reasoning: "high", + maxTokens: 1024, + onPayload: (payload) => { + capturedPayload = payload as AnthropicThinkingPayload; + return payload; + }, + }); + + let sawThinking = false; + + for await (const event of s) { + if (event.type === "thinking_start" || event.type === "thinking_delta" || event.type === "thinking_end") { + sawThinking = true; + } + } + + const response = await s.result(); + expect(response.stopReason, response.errorMessage).toBe("stop"); + expect(response.errorMessage).toBeFalsy(); + expect(capturedPayload?.thinking).toEqual({ type: "adaptive" }); + expect(capturedPayload?.output_config).toEqual({ effort: "high" }); + expect(sawThinking).toBe(true); + + const thinkingBlock = response.content.find((block) => block.type === "thinking"); + expect(thinkingBlock?.type).toBe("thinking"); + if (!thinkingBlock || thinkingBlock.type !== "thinking") { + throw new Error("Expected thinking block from Claude Opus 4.7"); + } + expect(typeof thinkingBlock.thinkingSignature).toBe("string"); + const thinkingSignature = thinkingBlock.thinkingSignature; + if (!thinkingSignature) { + throw new Error("Expected thinking signature from Claude Opus 4.7"); + } + expect(thinkingSignature.length).toBeGreaterThan(0); + + const text = response.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("") + .trim(); + expect(text).toBe("sum=353418362; divisibleBy11=yes"); + }); +}); diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index 8b154d48..7f406b30 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getModel } from "../src/models.js"; import { streamSimple } from "../src/stream.js"; -import type { Context, Model } from "../src/types.js"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.js"; interface AnthropicThinkingPayload { thinking?: { type: string; budget_tokens?: number }; @@ -14,7 +14,10 @@ function makePayloadCaptureContext(): Context { }; } -async function capturePayload(model: Model<"anthropic-messages">): Promise { +async function capturePayload( + model: Model<"anthropic-messages">, + options?: SimpleStreamOptions, +): Promise { let capturedPayload: AnthropicThinkingPayload | undefined; const payloadCaptureModel: Model<"anthropic-messages"> = { ...model, @@ -22,6 +25,7 @@ async function capturePayload(model: Model<"anthropic-messages">): Promise { capturedPayload = payload as AnthropicThinkingPayload; @@ -113,6 +117,27 @@ describe("Anthropic thinking disable payload", () => { expect(payload.thinking).toEqual({ type: "disabled" }); expect(payload.output_config).toBeUndefined(); }); + + it("sends thinking.type=disabled for Claude Opus 4.7 when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7")); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + }); + + it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "high" }); + + expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.output_config).toEqual({ effort: "high" }); + }); + + it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "xhigh" }); + + expect(payload.thinking).toEqual({ type: "adaptive" }); + expect(payload.output_config).toEqual({ effort: "xhigh" }); + }); }); describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic thinking disable E2E", () => { diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts new file mode 100644 index 00000000..b9f7c86f --- /dev/null +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { streamBedrock } from "../src/providers/amazon-bedrock.js"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.js"; + +interface BedrockThinkingPayload { + additionalModelRequestFields?: { + thinking?: { type: string; budget_tokens?: number }; + output_config?: { effort?: string }; + anthropic_beta?: string[]; + }; +} + +function makeContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +async function capturePayload( + model: Model<"bedrock-converse-stream">, + options?: SimpleStreamOptions, +): Promise { + let capturedPayload: BedrockThinkingPayload | undefined; + const s = streamBedrock(model, makeContext(), { + ...options, + reasoning: options?.reasoning ?? "high", + signal: AbortSignal.abort(), + onPayload: (payload) => { + capturedPayload = payload as BedrockThinkingPayload; + return payload; + }, + }); + + for await (const event of s) { + if (event.type === "error") { + break; + } + } + + if (!capturedPayload) { + throw new Error("Expected Bedrock payload to be captured before request abort"); + } + + return capturedPayload; +} + +describe("Bedrock thinking payload", () => { + it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => { + const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "global.anthropic.claude-opus-4-7-v1", + name: "Claude Opus 4.7 (Global)", + }; + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); + + it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => { + const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "global.anthropic.claude-opus-4-7-v1", + name: "Claude Opus 4.7 (Global)", + }; + + const payload = await capturePayload(model, { reasoning: "xhigh" }); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); +}); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index b3e5aaf6..c0c4db0c 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -8,6 +8,12 @@ describe("supportsXhigh", () => { expect(supportsXhigh(model!)).toBe(true); }); + it("returns true for Anthropic Opus 4.7 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-opus-4-7"); + expect(model).toBeDefined(); + expect(supportsXhigh(model!)).toBe(true); + }); + it("returns false for non-Opus Anthropic models", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); expect(model).toBeDefined();