mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(ai): fit Claude thinking budgets on Bedrock Converse (#51190)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }) } }
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user