From 20610e6645e77fff293354bec03d6872ae4bcedb Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:48:39 -0500 Subject: [PATCH] fix(ai): fit Claude thinking budgets on Bedrock Converse (#51190) --- packages/ai/src/protocols/bedrock-converse.ts | 32 +++++++++++++++++-- packages/ai/src/providers/amazon-bedrock.ts | 2 ++ .../ai/test/provider/bedrock-converse.test.ts | 23 +++++++++++++ packages/core/src/aisdk-native.ts | 16 +++++++--- packages/core/src/variant.ts | 3 +- packages/core/test/aisdk-native.test.ts | 11 +++++++ packages/core/test/variant.test.ts | 11 +++++++ 7 files changed, 90 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 08c9c2ce03b..5d7815ca718 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -441,12 +441,32 @@ const isHighReasoningEffort = Schema.is( }), ) +const Options = Schema.Struct({ + thinking: Schema.optional(Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number })), +}) +export type OptionsInput = typeof Options.Type +const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options)) +// Claude on Bedrock requires the thinking budget below `maxTokens`, with a minimum of 1,024. +const MIN_THINKING_BUDGET = 1_024 + const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const flattened = ProviderShared.flattenToolRequest(request) const generation = request.generation + const options = yield* decodeOptions(request.providerOptions ?? {}) const maxTokens = isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens + const thinking = + options.thinking === undefined + ? undefined + : { + type: "enabled", + budget_tokens: ProviderShared.fitThinkingBudget( + options.thinking.budgetTokens, + maxTokens, + MIN_THINKING_BUDGET, + ), + } // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in // tools → system → messages order to favour the highest-impact prefixes. const breakpoints = BedrockCache.breakpoints(request.model.id) @@ -487,9 +507,15 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: system, inferenceConfig, toolConfig, - // Converse's base inferenceConfig has no topK; Anthropic/Nova accept it - // as a model-specific field, so it goes through additionalModelRequestFields. - additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK }, + // Converse's base inferenceConfig has no topK or thinking; Anthropic/Nova accept them + // as model-specific fields, so they go through additionalModelRequestFields. + additionalModelRequestFields: + generation?.topK === undefined && thinking === undefined + ? undefined + : { + ...(generation?.topK === undefined ? {} : { top_k: generation.topK }), + ...(thinking === undefined ? {} : { thinking }), + }, } }) diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index d9bdf6817eb..5f35fc4f33d 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -31,6 +31,7 @@ export interface Settings extends ProviderPackage.Settings { readonly profile?: string readonly region?: string readonly topP?: number + readonly thinking?: BedrockConverse.OptionsInput["thinking"] } export const routes = [BedrockConverse.route] @@ -71,6 +72,7 @@ export const model: ProviderPackage.Definition["model"] = (modelID, se generation: settings.topP === undefined ? undefined : { topP: settings.topP }, headers: settings.headers === undefined ? undefined : { ...settings.headers }, http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + providerOptions: settings.thinking === undefined ? undefined : { thinking: settings.thinking }, profile: settings.profile, region: settings.region, }).model(modelID) diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index 77ca3422b88..9880f1b0fd6 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -244,6 +244,29 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("fits a Claude thinking budget below maxTokens", () => + Effect.gen(function* () { + const fields = (maxTokens: number, budgetTokens: number, topK?: number) => + compileRequest( + LLMRequest.update(baseRequest, { + model: AmazonBedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0", { + baseURL: "https://bedrock-runtime.test", + apiKey: "test-bearer", + thinking: { type: "enabled", budgetTokens }, + }), + generation: GenerationOptions.make({ maxTokens, topK }), + }), + ).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields)) + + expect(yield* fields(64_000, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 31_999 } }) + expect(yield* fields(20_000, 31_999, 40)).toEqual({ + top_k: 40, + thinking: { type: "enabled", budget_tokens: 10_000 }, + }) + expect(yield* fields(1_500, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 1_024 } }) + }), + ) + it.effect("omits additionalModelRequestFields when topK is unset", () => Effect.gen(function* () { const prepared = yield* compileRequest(baseRequest) diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index c5b2f69a887..712c2d4847a 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -151,8 +151,12 @@ type Overlay = { function options(replacement: string, modelID: string | undefined, settings: Legacy): Overlay { const converse = replacement === "@opencode/ai/providers/amazon-bedrock" && modelID !== undefined const kept = Struct.omit(settings, ["headers", "extraBody", "useCompletionUrls", ...OPENROUTER_KEYS]) + const thinking = converse ? bedrockThinking(modelID, settings) : undefined return { - settings: replacement.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(kept, converse) : kept, + settings: { + ...(replacement.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(kept, converse) : kept), + ...(thinking === undefined ? {} : { thinking }), + }, ...(settings.headers === undefined ? {} : { headers: settings.headers }), ...(settings.extraBody === undefined ? {} : { body: settings.extraBody }), ...(converse ? bedrockRequest(modelID, settings) : {}), @@ -196,6 +200,13 @@ function bedrockSettings(settings: Legacy, converse: boolean) { } } +// Claude's enabled budget is a typed setting so the protocol can fit it under the output limit. +function bedrockThinking(modelID: string | undefined, settings: Legacy) { + const reasoning = settings.reasoningConfig + if (!modelID?.includes("anthropic") || reasoning?.type !== "enabled" || reasoning.budgetTokens === undefined) return + return { type: "enabled", budgetTokens: reasoning.budgetTokens } +} + function bedrockRequest(modelID: string | undefined, settings: Legacy): Pick { const additional = settings.additionalModelRequestFields ?? {} const reasoning = settings.reasoningConfig @@ -210,9 +221,6 @@ function bedrockRequest(modelID: string | undefined, settings: Legacy): Pick 0 ? { anthropic_beta: [...(additional.anthropic_beta ?? []), ...betas] } : {}), - ...(anthropic && type === "enabled" && budget !== undefined - ? { thinking: { type: "enabled", budget_tokens: budget } } - : {}), ...(anthropic && type === "adaptive" ? { thinking: { type: "adaptive", ...(display === undefined ? {} : { display }) } } : {}), diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index 7c2476a8afd..f5792727ae2 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -398,8 +398,9 @@ const bedrockConverse: Protocol = (model, support) => { model, support, (tokens) => + // Claude's budget is a typed setting so the protocol can fit it under the output limit. claude - ? fields({ thinking: { type: "enabled", budget_tokens: tokens } }) + ? { settings: { thinking: { type: "enabled", budgetTokens: tokens } } } : fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }), claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output, ) diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 998dd1f1231..6a4c79d87ae 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -247,6 +247,17 @@ describe("AISDKNative", () => { }, }) + expect( + map( + "@ai-sdk/amazon-bedrock", + { reasoningConfig: { type: "enabled", budgetTokens: 12_000 } }, + "anthropic.claude-sonnet-4-5-20250929-v1:0", + ), + ).toEqual({ + package: "@opencode/ai/providers/amazon-bedrock", + settings: { thinking: { type: "enabled", budgetTokens: 12_000 } }, + }) + // gpt-oss (Harmony) keeps the flat chat-completions field. expect( map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "high" } }, "openai.gpt-oss-120b-1:0") diff --git a/packages/core/test/variant.test.ts b/packages/core/test/variant.test.ts index 5c58e71f0a5..1366db778d4 100644 --- a/packages/core/test/variant.test.ts +++ b/packages/core/test/variant.test.ts @@ -196,6 +196,17 @@ test("spells Chat Completions variants for direct providers", () => { ]) }) +test("spells Bedrock Converse Claude budgets as a thinking setting", () => { + expect( + resolve(model("@opencode/ai/providers/amazon-bedrock", "us.anthropic.claude-haiku-4-5-20251001-v1:0", 64_000), [ + { type: "budget_tokens", min: 1024 }, + ]), + ).toEqual([ + { id: "high", settings: { thinking: { type: "enabled", budgetTokens: 16_000 } } }, + { id: "max", settings: { thinking: { type: "enabled", budgetTokens: 31_999 } } }, + ]) +}) + test("spells Bedrock Converse effort for Grok and Nova", () => { const supports: Variant.Support[] = [{ type: "effort", values: ["low", "xhigh"] }] expect(resolve(model("@opencode/ai/providers/amazon-bedrock", "us.xai.grok-4.6"), supports)).toEqual([