feat(ai): add typed Bedrock Converse options (#59171)

This commit is contained in:
Aiden Cline
2026-10-26 01:02:33 -05:00
committed by GitHub
Unverified
parent f7a9f1bf06
commit 74556a25c7
9 changed files with 586 additions and 37 deletions
+124 -15
View File
@@ -13,6 +13,7 @@ import {
type LLMRequest,
type LanguageModel,
type ProviderMetadata,
type ProviderOptions,
type ReasoningPart,
type ToolCallPart,
type ToolDefinition,
@@ -24,7 +25,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { BedrockAuth } from "./utils/bedrock-auth.js"
import { BedrockCache } from "./utils/bedrock-cache.js"
import { BedrockMedia } from "./utils/bedrock-media.js"
import { supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
import { claudeVersion, supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -125,6 +126,12 @@ const BedrockToolChoice = Schema.Union([
Schema.Struct({ tool: Schema.Struct({ name: Schema.String }) }),
])
// Value sets from the Converse API reference. Unknown values pass through for forward compatibility.
const ServiceTierType = ProviderShared.knownString<"priority" | "default" | "flex" | "reserved">()
const GuardrailTrace = ProviderShared.knownString<"enabled" | "disabled" | "enabled_full">()
const GuardrailStreamProcessingMode = ProviderShared.knownString<"sync" | "async">()
const TextFormatType = ProviderShared.knownString<"json_schema">()
const BedrockBodyFields = {
modelId: Schema.String,
messages: Schema.Array(BedrockMessage),
@@ -144,6 +151,30 @@ const BedrockBodyFields = {
}),
),
additionalModelRequestFields: Schema.optional(JsonObject),
serviceTier: Schema.optional(Schema.Struct({ type: ServiceTierType })),
guardrailConfig: Schema.optional(
Schema.Struct({
guardrailIdentifier: Schema.String,
guardrailVersion: Schema.String,
trace: Schema.optional(GuardrailTrace),
streamProcessingMode: Schema.optional(GuardrailStreamProcessingMode),
}),
),
outputConfig: Schema.optional(
Schema.Struct({
textFormat: Schema.Struct({
type: TextFormatType,
structure: Schema.Struct({
jsonSchema: Schema.Struct({
schema: Schema.String,
name: Schema.optional(Schema.String),
description: Schema.optional(Schema.String),
}),
}),
}),
}),
),
requestMetadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}
const BedrockConverseBody = Schema.Struct(BedrockBodyFields)
export type BedrockConverseBody = Schema.Schema.Type<typeof BedrockConverseBody>
@@ -467,10 +498,57 @@ const isHighReasoningEffort = Schema.is(
}),
)
// Claude 4.6+ pairs effort with adaptive thinking; earlier Claude models take effort beside a manual budget.
const supportsAdaptiveThinking = (model: LanguageModel) => {
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 4 || (version.major === 4 && version.minor >= 6))
}
const effortFields = (model: LanguageModel, effort: string) => {
if (model.id.includes("anthropic")) return { output_config: { effort } }
// gpt-oss takes the flat chat-completions spelling; newer OpenAI and xAI models take the Responses one.
if (model.id.includes("openai.gpt-oss")) return { reasoning_effort: effort }
if (model.id.includes("openai.") || model.id.includes("xai.")) return { reasoning: { effort } }
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
}
const Options = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number })),
/** Claude thinking. `adaptive` and `disabled` apply to Claude models only. */
thinking: Schema.optional(
Schema.Union([
Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number }),
Schema.Struct({
type: Schema.Literal("adaptive"),
display: Schema.optional(ProviderShared.knownString<"summarized" | "omitted">()),
}),
Schema.Struct({ type: Schema.Literal("disabled") }),
]),
),
/** Reasoning effort, sent in the field each model family reads. */
effort: Schema.optional(ProviderShared.knownString<"low" | "medium" | "high" | "xhigh" | "max">()),
serviceTier: Schema.optional(ServiceTierType),
/** Constrains the response text to a JSON schema. */
outputFormat: Schema.optional(
Schema.Struct({
type: TextFormatType,
schema: JsonObject,
name: Schema.optional(Schema.String),
description: Schema.optional(Schema.String),
}),
),
guardrail: Schema.optional(
Schema.Struct({
identifier: Schema.String,
version: Schema.String,
trace: Schema.optional(GuardrailTrace),
streamProcessingMode: Schema.optional(GuardrailStreamProcessingMode),
}),
),
/** Key-value pairs for filtering invocation logs. */
requestMetadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type OptionsInput = typeof Options.Type
/** Caller-facing provider options; unknown keys are accepted and ignored. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
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
@@ -486,7 +564,7 @@ const isThinkingDisabled = Schema.is(
// Claude 5.1+ binds each thinking signature to the prefix above it. Ask Bedrock to drop the affected blocks instead of
// failing when that prefix changes. `http.body` overlays this field by field, so callers can still override it.
const applyThinkingBindingDefault = (request: LLMRequest, thinking: Readonly<Record<string, unknown>> | undefined) => {
if (isThinkingDisabled(request.http?.body)) return thinking
if (thinking?.type === "disabled" || isThinkingDisabled(request.http?.body)) return thinking
if (!supportsThinkingBlockBinding(request.model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
@@ -500,20 +578,32 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const maxTokens =
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
isNova2(request.model) && (options.effort === "high" || isHighReasoningEffort(request.http?.body))
? undefined
: generation?.maxTokens
const thinking = applyThinkingBindingDefault(
request,
options.thinking === undefined
? undefined
: {
(() => {
if (options.thinking?.type === "enabled")
return {
type: "enabled",
budget_tokens: ProviderShared.fitThinkingBudget(
options.thinking.budgetTokens,
maxTokens,
MIN_THINKING_BUDGET,
),
},
}
if (options.thinking !== undefined) return options.thinking
if (options.effort !== undefined && supportsAdaptiveThinking(request.model)) return { type: "adaptive" }
})(),
)
const additionalModelRequestFields = {
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
...(thinking === undefined ? {} : { thinking }),
...(options.effort === undefined ? {} : effortFields(request.model, options.effort)),
// Converse takes Anthropic betas in the body, and Bedrock rejects `block_binding` without this one.
...(thinking?.block_binding === undefined ? {} : { anthropic_beta: [THINKING_BINDING_BETA] }),
}
// 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)
@@ -554,17 +644,36 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
system,
inferenceConfig,
toolConfig,
// Converse's base inferenceConfig has no topK or thinking; Anthropic/Nova accept them
// Converse's base inferenceConfig has no topK, thinking or effort; models accept them
// as model-specific fields, so they go through additionalModelRequestFields.
additionalModelRequestFields:
generation?.topK === undefined && thinking === undefined
Object.keys(additionalModelRequestFields).length === 0 ? undefined : additionalModelRequestFields,
serviceTier: options.serviceTier === undefined ? undefined : { type: options.serviceTier },
guardrailConfig:
options.guardrail === undefined
? undefined
: {
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
...(thinking === undefined ? {} : { thinking }),
// Converse takes Anthropic betas in the body, and Bedrock rejects `block_binding` without this one.
...(thinking?.block_binding === undefined ? {} : { anthropic_beta: [THINKING_BINDING_BETA] }),
guardrailIdentifier: options.guardrail.identifier,
guardrailVersion: options.guardrail.version,
trace: options.guardrail.trace,
streamProcessingMode: options.guardrail.streamProcessingMode,
},
outputConfig:
options.outputFormat === undefined
? undefined
: {
textFormat: {
type: options.outputFormat.type,
structure: {
jsonSchema: {
schema: ProviderShared.encodeJson(options.outputFormat.schema),
name: options.outputFormat.name,
description: options.outputFormat.description,
},
},
},
},
requestMetadata: options.requestMetadata,
}
})
+26 -22
View File
@@ -21,18 +21,19 @@ export type Config = RouteDefaultsInput & {
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string
readonly providerOptions?: BedrockConverse.OptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly thinking?: BedrockConverse.OptionsInput["thinking"]
}
export type Settings = ProviderPackage.Settings &
BedrockConverse.OptionsInput & {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials
readonly profile?: string
readonly region?: string
readonly topP?: number
}
export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
@@ -57,22 +58,25 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<BedrockConverse.OptionsInput>({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, BedrockConverse.OptionsInput>["model"] = (
modelID,
{ apiKey, auth, baseURL, credentials, profile, region, topP, headers, body, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
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,
apiKey,
auth,
baseURL,
credentials,
generation: topP === undefined ? undefined : { topP },
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
profile,
region,
}).model(modelID)
@@ -0,0 +1,33 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:bedrock-converse-options",
"provider:amazon-bedrock",
"protocol:bedrock-converse"
],
"name": "bedrock-converse-options/constrains-claude-output-to-a-json-schema",
"recordedAt": "2026-10-26T05:24:46.975Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-sonnet-4-6/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.anthropic.claude-sonnet-4-6\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is 17*23?\"}]}],\"inferenceConfig\":{\"maxTokens\":200},\"outputConfig\":{\"textFormat\":{\"type\":\"json_schema\",\"structure\":{\"jsonSchema\":{\"schema\":\"{\\\"type\\\":\\\"object\\\",\\\"properties\\\":{\\\"product\\\":{\\\"type\\\":\\\"integer\\\"}},\\\"required\\\":[\\\"product\\\"],\\\"additionalProperties\\\":false}\",\"name\":\"answer\"}}}}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAArQAAAFJo8LSWCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViIsInJvbGUiOiJhc3Npc3RhbnQiff/V9LIAAADEAAAAV4y4VSULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0Ijoie1wiIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OIn1nS/0LAAAAuQAAAFeN+nFbCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6InByb2R1Y3RcIjoifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYifabu2fMAAACmAAAAV29KcQgLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiMzkxIn0sInAiOiJhYmNkZWZnaGlqIn1V027xAAAAvwAAAFcCuoT7CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6In0ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSksifT8Lk2QAAAC1AAAAVj8NrMwLOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXIn2NFJ+9AAAAoAAAAFEJaSGdCzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERSIsInN0b3BSZWFzb24iOiJlbmRfdHVybiJ9p5BdEgAAANsAAABOCmP9tgs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjEyMDh9LCJwIjoiYWJjZCIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjoxNTUsIm91dHB1dFRva2VucyI6OCwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjE2M319Zt/PuA==",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:bedrock-converse-options",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"reasoning"
],
"name": "bedrock-converse-options/sends-gpt-oss-reasoning-effort-with-the-priority-tier",
"recordedAt": "2026-10-26T05:24:44.284Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/openai.gpt-oss-120b-1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"openai.gpt-oss-120b-1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is 17*23? Reply with the number only.\"}]}],\"inferenceConfig\":{\"maxTokens\":1000},\"additionalModelRequestFields\":{\"reasoning_effort\":\"low\"},\"serviceTier\":{\"type\":\"priority\"},\"requestMetadata\":{\"suite\":\"opencode-ai\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAnQAAAFLJ0QwQCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGIiwicm9sZSI6ImFzc2lzdGFudCJ9EQf77QAAAMkAAABXdCiRlAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViJ9oXumJQAAAMAAAABWDj/Dcws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyJ9EPTSwQAAAMkAAABXdCiRlAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjEsImRlbHRhIjp7InJlYXNvbmluZ0NvbnRlbnQiOnsidGV4dCI6Ikp1c3QgY29tcHV0ZSAxNyoyMyA9IDM5MS4ifX0sInAiOiJhYiJ9u2fddQAAALcAAABWRc3/rAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9hmJaHgAAAKMAAABXp6r+eAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjIsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmcifT1TPPUAAADBAAAAVjNf6sMLOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjIsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1Njc4In0WfQjAAAAAnwAAAFEqGA7KCzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNEIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2rNcIbAAABIQAAAE7Du2bvCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTU3NX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OIiwic2VydmljZVRpZXIiOnsidHlwZSI6InByaW9yaXR5In0sInVzYWdlIjp7ImlucHV0VG9rZW5zIjo4MCwib3V0cHV0VG9rZW5zIjoyMSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjEwMX19od/llw==",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:bedrock-converse-options",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"reasoning"
],
"name": "bedrock-converse-options/sends-nova-2-reasoning-effort",
"recordedAt": "2026-10-26T05:24:45.686Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-2-lite-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.amazon.nova-2-lite-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is 17*23? Reply with the number only.\"}]}],\"inferenceConfig\":{\"maxTokens\":1000},\"additionalModelRequestFields\":{\"reasoningConfig\":{\"type\":\"enabled\",\"maxReasoningEffort\":\"low\"}}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAmAAAAFIBMYNgCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QSIsInJvbGUiOiJhc3Npc3RhbnQifRK9PwwAAADaAAAAV1NofMYLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJyZWFzb25pbmdDb250ZW50Ijp7InRleHQiOiJbUkVEQUNURURdIn19LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSCJ966snJwAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InJlYXNvbmluZ0NvbnRlbnQiOnsidGV4dCI6Ii4ifX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9txtPJQAAAIkAAABWW9z5Sws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlIn3nHPCvAAAApwAAAFdSKli4CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MSwiZGVsdGEiOnsidGV4dCI6IjM5MSJ9LCJwIjoiYWJjZGVmZ2hpamsifVFEEycAAACHAAAAVuTsRyoLOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjEsInAiOiJhYmMifVAMp9gAAACjAAAAUU7JW00LOmV2ZW50LXR5cGUHAAttZXNzYWdlU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7InAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdIIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn3wGF+JAAABCwAAAE5IylFKCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTMzMn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWCIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjoxMDksIm91dHB1dFRva2VucyI6MjQ2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MzU1fX3ERMiI",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,33 @@
import { LLM } from "../../src/effect.js"
import { AmazonBedrock } from "../../src/providers.js"
const model = AmazonBedrock.provider.model("us.anthropic.claude-sonnet-4-6")
LLM.request({
model,
prompt: "Hello",
providerOptions: {
effort: "future-effort",
serviceTier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
guardrail: { identifier: "abc123", version: "DRAFT", trace: "future-trace", streamProcessingMode: "future-mode" },
outputFormat: { type: "future-format", schema: { type: "object" } },
requestMetadata: { suite: "opencode-ai" },
},
})
AmazonBedrock.model("us.amazon.nova-2-lite-v1:0", { effort: "low", serviceTier: "flex" })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Bedrock thinking modes are a fixed union.
providerOptions: { thinking: { type: "automatic" } },
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Converse request metadata values are strings.
providerOptions: { requestMetadata: { attempt: 1 } },
})
@@ -0,0 +1,119 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src/effect.js"
import { LLMClient } from "../../src/route.js"
import { AmazonBedrock } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
const bedrock = (id: string, settings: Omit<Parameters<typeof AmazonBedrock.model>[1], "apiKey" | "region">) =>
AmazonBedrock.model(id, {
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
...settings,
})
const recorded = recordedTests({
prefix: "bedrock-converse-options",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
})
describe("Bedrock Converse options recorded", () => {
// Claude on Bedrock only accepts the default tier; other tiers fail with "not supported for this model".
recorded.effect.with(
"sends Claude effort with adaptive thinking, service tier and request metadata",
{ tags: ["reasoning"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_options_claude",
model: bedrock("us.anthropic.claude-sonnet-4-6", {
effort: "high",
thinking: { type: "adaptive", display: "summarized" },
serviceTier: "default",
requestMetadata: { suite: "opencode-ai" },
}),
prompt: "How many primes are below 60? Reply with the number only.",
cache: "none",
generation: { maxTokens: 4_000 },
}),
)
expect(response.finishReason?.normalized).toBe("stop")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.text).toContain("17")
}),
)
recorded.effect.with("sends gpt-oss reasoning effort with the priority tier", { tags: ["reasoning"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_options_gpt_oss",
model: bedrock("openai.gpt-oss-120b-1:0", {
effort: "low",
serviceTier: "priority",
requestMetadata: { suite: "opencode-ai" },
}),
prompt: "What is 17*23? Reply with the number only.",
cache: "none",
generation: { maxTokens: 1_000 },
}),
)
expect(response.finishReason?.normalized).toBe("stop")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.text).toContain("391")
}),
)
recorded.effect.with("sends Nova 2 reasoning effort", { tags: ["reasoning"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_options_nova",
model: bedrock("us.amazon.nova-2-lite-v1:0", { effort: "low" }),
prompt: "What is 17*23? Reply with the number only.",
cache: "none",
generation: { maxTokens: 1_000 },
}),
)
expect(response.finishReason?.normalized).toBe("stop")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.text).toContain("391")
}),
)
recorded.effect("constrains Claude output to a JSON schema", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_options_output_format",
model: bedrock("us.anthropic.claude-sonnet-4-6", {
outputFormat: {
type: "json_schema",
name: "answer",
schema: {
type: "object",
properties: { product: { type: "integer" } },
required: ["product"],
additionalProperties: false,
},
},
}),
prompt: "What is 17*23?",
cache: "none",
generation: { maxTokens: 200 },
}),
)
expect(response.finishReason?.normalized).toBe("stop")
expect(JSON.parse(response.text)).toEqual({ product: 391 })
}),
)
})
@@ -0,0 +1,149 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src/effect.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
const bedrock = (id: string, settings: Omit<Parameters<typeof AmazonBedrock.model>[1], "apiKey" | "baseURL">) =>
AmazonBedrock.model(id, { baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer", ...settings })
const request = (model: ReturnType<typeof bedrock>) =>
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 64 } })
const body = (model: ReturnType<typeof bedrock>) =>
compileRequest(request(model)).pipe(Effect.map((prepared) => prepared.body))
const fields = (model: ReturnType<typeof bedrock>) =>
body(model).pipe(Effect.map((prepared) => prepared.additionalModelRequestFields))
const binding = { prefix_mismatch_behavior: "drop_block" }
const beta = ["thinking-binding-controls-2026-08-01"]
describe("Bedrock Converse options", () => {
it.effect("maps effort to each model family's field", () =>
Effect.gen(function* () {
expect(yield* fields(bedrock("us.anthropic.claude-sonnet-4-6", { effort: "high" }))).toEqual({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
})
expect(yield* fields(bedrock("global.anthropic.claude-opus-5-5", { effort: "high" }))).toEqual({
thinking: { type: "adaptive", block_binding: binding },
output_config: { effort: "high" },
anthropic_beta: beta,
})
expect(yield* fields(bedrock("openai.gpt-oss-120b-1:0", { effort: "low" }))).toEqual({ reasoning_effort: "low" })
expect(yield* fields(bedrock("openai.gpt-5.6", { effort: "low" }))).toEqual({ reasoning: { effort: "low" } })
expect(yield* fields(bedrock("us.xai.grok-4.6", { effort: "low" }))).toEqual({ reasoning: { effort: "low" } })
expect(yield* fields(bedrock("us.amazon.nova-2-lite-v1:0", { effort: "medium" }))).toEqual({
reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" },
})
}),
)
for (const id of ["us.anthropic.claude-opus-4-5-20251101-v1:0", "us.anthropic.claude-3-7-sonnet-20250219-v1:0"])
it.effect(`keeps ${id} effort beside its manual thinking budget`, () =>
Effect.gen(function* () {
expect(yield* fields(bedrock(id, { effort: "high" }))).toEqual({ output_config: { effort: "high" } })
expect(
yield* fields(bedrock(id, { effort: "high", thinking: { type: "enabled", budgetTokens: 1_024 } })),
).toEqual({ thinking: { type: "enabled", budget_tokens: 1_024 }, output_config: { effort: "high" } })
}),
)
it.effect("sends explicit adaptive and disabled Claude thinking", () =>
Effect.gen(function* () {
expect(
yield* fields(
bedrock("us.anthropic.claude-sonnet-4-6", {
effort: "low",
thinking: { type: "adaptive", display: "summarized" },
}),
),
).toEqual({ thinking: { type: "adaptive", display: "summarized" }, output_config: { effort: "low" } })
expect(yield* fields(bedrock("global.anthropic.claude-opus-5-5", { thinking: { type: "disabled" } }))).toEqual({
thinking: { type: "disabled" },
})
}),
)
it.effect("omits maxTokens for Nova 2 at high effort", () =>
Effect.gen(function* () {
expect((yield* body(bedrock("us.amazon.nova-2-lite-v1:0", { effort: "high" }))).inferenceConfig).toBeUndefined()
}),
)
it.effect("sends service tier, guardrail, structured output and request metadata", () =>
Effect.gen(function* () {
const prepared = yield* body(
bedrock("us.anthropic.claude-sonnet-4-6", {
serviceTier: "priority",
guardrail: { identifier: "abc123", version: "1", trace: "enabled", streamProcessingMode: "async" },
outputFormat: {
type: "json_schema",
name: "answer",
schema: { type: "object", properties: { product: { type: "integer" } } },
},
requestMetadata: { suite: "opencode-ai" },
}),
)
expect(prepared).toEqual({
modelId: "us.anthropic.claude-sonnet-4-6",
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
inferenceConfig: { maxTokens: 64 },
serviceTier: { type: "priority" },
guardrailConfig: {
guardrailIdentifier: "abc123",
guardrailVersion: "1",
trace: "enabled",
streamProcessingMode: "async",
},
outputConfig: {
textFormat: {
type: "json_schema",
structure: {
jsonSchema: {
name: "answer",
schema: '{"type":"object","properties":{"product":{"type":"integer"}}}',
},
},
},
},
requestMetadata: { suite: "opencode-ai" },
})
}),
)
it.effect("lets http.body override typed options field by field", () =>
Effect.gen(function* () {
const sent: string[] = []
yield* LLMClient.generate(
request(
bedrock("us.anthropic.claude-sonnet-4-6", {
effort: "low",
serviceTier: "priority",
body: { additionalModelRequestFields: { output_config: { effort: "max" } }, serviceTier: { type: "flex" } },
}),
),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
sent.push(input.text)
return input.respond("{}", { status: 400 })
}),
),
),
Effect.ignore,
)
expect(JSON.parse(sent[0]!)).toMatchObject({
additionalModelRequestFields: { thinking: { type: "adaptive" }, output_config: { effort: "max" } },
serviceTier: { type: "flex" },
})
}),
)
})