fix(ai): fit Claude thinking budgets on Bedrock Converse (#51190)

This commit is contained in:
Aiden Cline
2026-09-24 13:48:39 -05:00
committed by GitHub
parent 7f245b0968
commit 20610e6645
7 changed files with 90 additions and 8 deletions
+29 -3
View File
@@ -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 }),
},
}
})
@@ -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<Settings>["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)
@@ -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)
+12 -4
View File
@@ -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<Overlay, "body"> {
const additional = settings.additionalModelRequestFields ?? {}
const reasoning = settings.reasoningConfig
@@ -210,9 +221,6 @@ function bedrockRequest(modelID: string | undefined, settings: Legacy): Pick<Ove
const betas = settings.anthropicBeta ?? []
const fields = Provider.mergeOverlay(additional, {
...(betas.length > 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 }) } }
: {}),
+2 -1
View File
@@ -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,
)
+11
View File
@@ -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")
+11
View File
@@ -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([