fix(aibridge/intercept/messages): convert enabled thinking to adaptive for Bedrock Opus 4.7+ (#25335)

*Disclaimer: implemented by a Coder Agent using Claude Opus 4.6/4.7*

Fixes
[coder/aibridge#280](https://github.com/coder/aibridge/issues/280).

Claude Opus 4.7 (and future adaptive-only Bedrock models) reject the
legacy `thinking.type: "enabled"` + `budget_tokens` shape with a 400.
Claude Code falls back to that shape when it cannot read the upstream
model's capability metadata, which is exactly the case when AI Bridge
sits between the client and Bedrock. Pinning back to Opus 4.6 is the
only operator workaround today.

This is the counterpart to the `adaptive -> enabled` conversion added in
[coder/aibridge#225](https://github.com/coder/aibridge/pull/225) for
older Bedrock models.

## Behavior

- New `bedrockModelRequiresAdaptiveThinking()` helper matches Opus 4.7
(covers `us.anthropic.claude-opus-4-7`, ARN-style application inference
profile names that include the model ID, etc.).
- New `RequestPayload.convertEnabledThinkingForBedrock()` rewrites
`thinking: {type: enabled, budget_tokens: N}` to `thinking: {type:
adaptive}`. The budget hint is dropped; an explicit
`output_config.effort` from the caller is preserved naturally because we
never touch that field. We deliberately do **not** derive an effort
label from the budget (see decision log).
- `removeUnsupportedBedrockFields` learns a variadic `exemptFields`
parameter. Adaptive-only models support `output_config` natively (no
beta flag required), so `augmentRequestForBedrock` exempts that field
for those models.
- Bedrock Opus 4.7 accepts `output_config.effort` but rejects
`output_config.format` (structured outputs) with the same "Extra inputs
are not permitted" 400. The generic strip pass operates at top-level
granularity only, so a small targeted pass drops `output_config.format`
after the top-level strip for adaptive-only models.

The whole Bedrock thinking-type shim block carries a header comment
flagging it as temporary; a planned native Bedrock provider removes the
impedance mismatch and lets us delete it.

## Out of scope

The issue calls out a possible follow-up around `Anthropic-Beta:
interleaved-thinking-2025-05-14` for adaptive-only models; best evidence
is that Opus 4.7 still accepts those flags, so this PR is a no-op there.

<details>
<summary>Decision log</summary>

- `bedrockModelSupportsAdaptiveThinking` now also returns `true` for
adaptive-only models. That keeps the existing
`convertAdaptiveThinkingForBedrock` branch from running on Opus 4.7
(which would otherwise be incorrect; `adaptive` is the supported native
type there), and the new `convertEnabledThinkingForBedrock` runs only
for adaptive-only models via the explicit
`bedrockModelRequiresAdaptiveThinking` switch case. The two model sets
are disjoint by construction.
- The reverse conversion does **not** derive `output_config.effort` from
`budget_tokens / max_tokens`. The two thinking shapes encode different
intents (`enabled+budget` is "give me exactly N tokens,"
`adaptive[+effort]` is "model, pick a budget, optionally biased") and
there is no canonical mapping between them. An earlier draft of this PR
derived effort via midpoints of an invented anchor table; it was
symmetric-looking but lossy and required a lot of scaffolding (sorted
anchors, init-time invariant guard, round-trip tests) to keep two halves
consistent. The reverse direction now just rewrites the shape, which is
honest about the information loss and matches platform-defined adaptive
behavior when no effort hint is present.
- `output_config.format` is stripped only for adaptive-only models.
Other Bedrock models either don't get `output_config` through at all
(top-level strip handles them) or accept it via a beta flag that may
imply broader feature support. Easy to widen if the same 400 shows up
elsewhere.
- I chose `variadic exemptFields ...string` over passing the model down
to `removeUnsupportedBedrockFields`, to keep that function focused on
stripping and to localise the model-aware policy in
`augmentRequestForBedrock`.

</details>
This commit is contained in:
Danny Kopping
2026-05-15 10:11:41 +02:00
committed by GitHub
parent 96ea2465b7
commit c6ab379c32
4 changed files with 271 additions and 29 deletions
+52 -6
View File
@@ -341,7 +341,8 @@ func (*interceptionBase) withAWSBedrockOptions(ctx context.Context, cfg *aibconf
// augmentRequestForBedrock will change the model used for the request since AWS Bedrock doesn't support
// Anthropics' model names. It also converts adaptive thinking to enabled with a budget for models that
// don't support adaptive thinking natively.
// don't support adaptive thinking natively, or enabled thinking to adaptive for models that only support
// adaptive (Opus 4.7+).
func (i *interceptionBase) augmentRequestForBedrock() {
if i.bedrockCfg == nil {
return
@@ -355,7 +356,21 @@ func (i *interceptionBase) augmentRequestForBedrock() {
}
i.reqPayload = updated
if !bedrockModelSupportsAdaptiveThinking(model) {
switch {
case bedrockModelRequiresAdaptiveThinking(model):
// Symmetric conversion for adaptive-only models (Opus 4.7+): rewrite
// thinking.type "enabled" with budget_tokens to the "adaptive" shape,
// since Bedrock returns 400 for these models when the legacy shape is
// used. Claude Code falls back to the legacy shape when it cannot
// read the upstream model's capability metadata (which is the case
// when AI Bridge is in the path).
updated, err = i.reqPayload.convertEnabledThinkingForBedrock()
if err != nil {
i.logger.Warn(context.Background(), "failed to convert enabled thinking for Bedrock", slog.Error(err))
return
}
i.reqPayload = updated
case !bedrockModelSupportsAdaptiveThinking(model):
updated, err = i.reqPayload.convertAdaptiveThinkingForBedrock()
if err != nil {
i.logger.Warn(context.Background(), "failed to convert adaptive thinking for Bedrock", slog.Error(err))
@@ -370,21 +385,52 @@ func (i *interceptionBase) augmentRequestForBedrock() {
filterBedrockBetaFlags(i.clientHeaders, model)
}
// Strip body fields that Bedrock does not accept.
updated, err = i.reqPayload.removeUnsupportedBedrockFields(i.clientHeaders)
// Strip body fields that Bedrock does not accept. Adaptive-only models
// (Opus 4.7+) support output_config natively without a beta flag, so
// keep it for those models even when the effort-2025-11-24 flag is
// absent from the request.
var exemptFields []string
if bedrockModelRequiresAdaptiveThinking(model) {
exemptFields = append(exemptFields, messagesReqPathOutputConfig)
}
updated, err = i.reqPayload.removeUnsupportedBedrockFields(i.clientHeaders, exemptFields...)
if err != nil {
i.logger.Warn(context.Background(), "failed to remove unsupported fields for Bedrock", slog.Error(err))
return
}
i.reqPayload = updated
// Adaptive-only models accept output_config but reject some of its
// sub-fields (currently: output_config.format). Strip those after the
// top-level pass has decided to keep output_config.
if bedrockModelRequiresAdaptiveThinking(model) {
updated, err = i.reqPayload.removeBedrockUnsupportedOutputConfigSubFields()
if err != nil {
i.logger.Warn(context.Background(), "failed to strip unsupported output_config sub-fields for Bedrock", slog.Error(err))
return
}
i.reqPayload = updated
}
}
// bedrockModelSupportsAdaptiveThinking returns true if the given Bedrock model ID
// supports the "adaptive" thinking type natively (i.e. Claude 4.6 models).
// supports the "adaptive" thinking type natively (i.e. Claude 4.6 models, and
// adaptive-only models such as Opus 4.7+).
// See https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-adaptive-thinking.html
func bedrockModelSupportsAdaptiveThinking(model string) bool {
return strings.Contains(model, "anthropic.claude-opus-4-6") ||
strings.Contains(model, "anthropic.claude-sonnet-4-6")
strings.Contains(model, "anthropic.claude-sonnet-4-6") ||
bedrockModelRequiresAdaptiveThinking(model)
}
// bedrockModelRequiresAdaptiveThinking returns true if the given Bedrock model
// ID only supports the "adaptive" thinking type and rejects the legacy
// "enabled" + budget_tokens shape with a 400. Claude Opus 4.7 was the first
// model in this category.
//
// See https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-7.html
func bedrockModelRequiresAdaptiveThinking(model string) bool {
return strings.Contains(model, "anthropic.claude-opus-4-7")
}
// filterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta
+64 -1
View File
@@ -704,7 +704,8 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
clientBetaFlags string
expectThinkingType string
expectBudgetTokens int64 // 0 means budget_tokens should not be present
expectBudgetTokens int64 // 0 means budget_tokens should not be present
expectEffort string // expected output_config.effort; "" means must not be present
expectRemovedFields []string
expectKeptFields []string
expectBetaValues []string // expected separate Anthropic-Beta header values
@@ -759,6 +760,7 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
bedrockModel: "anthropic.claude-opus-4-5-20250929-v1:0",
clientBetaFlags: "effort-2025-11-24,interleaved-thinking-2025-05-14",
requestBody: `{"max_tokens":10000,"output_config":{"effort":"high"}}`,
expectEffort: "high",
expectKeptFields: []string{"output_config"},
expectBetaValues: []string{"effort-2025-11-24", "interleaved-thinking-2025-05-14"},
},
@@ -806,6 +808,60 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
requestBody: `{"max_tokens":10000,"output_config":{"effort":"high"},"metadata":{"user_id":"u123"},"service_tier":"auto","container":"ctr_abc","inference_geo":"us","context_management":{"type":"auto"}}`,
expectRemovedFields: []string{"output_config", "metadata", "service_tier", "container", "inference_geo", "context_management"},
},
// Adaptive-only models (Opus 4.7+), see coder/aibridge#280. The
// conversion drops budget_tokens and flips the type; an explicit
// output_config.effort from the caller is preserved, but none is
// fabricated when absent.
{
name: "opus_4_7_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget",
bedrockModel: "us.anthropic.claude-opus-4-7",
requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`,
expectThinkingType: "adaptive",
},
{
name: "opus_4_7_model_with_adaptive_thinking_is_unchanged",
bedrockModel: "us.anthropic.claude-opus-4-7",
requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"}}`,
expectThinkingType: "adaptive",
},
{
name: "opus_4_7_model_without_thinking_field_is_unchanged",
bedrockModel: "us.anthropic.claude-opus-4-7",
requestBody: `{"max_tokens":10000}`,
},
{
name: "opus_4_7_model_preserves_explicit_output_config_effort",
bedrockModel: "us.anthropic.claude-opus-4-7",
requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":2000},"output_config":{"effort":"max"}}`,
expectThinkingType: "adaptive",
expectEffort: "max",
expectKeptFields: []string{"output_config"},
},
{
name: "opus_4_7_model_keeps_output_config_without_effort_beta_flag",
bedrockModel: "us.anthropic.claude-opus-4-7",
requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectThinkingType: "adaptive",
expectEffort: "high",
expectKeptFields: []string{"output_config"},
},
{
name: "arn_style_opus_4_7_application_inference_profile_is_treated_as_adaptive_only",
bedrockModel: "arn:aws:bedrock:us-east-1:123:application-inference-profile/global.anthropic.claude-opus-4-7",
requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":8000}}`,
expectThinkingType: "adaptive",
},
{
// Opus 4.7 on Bedrock rejects output_config.format (structured
// outputs) with a 400 even though it accepts output_config.effort.
name: "opus_4_7_model_strips_output_config_format_but_keeps_effort",
bedrockModel: "us.anthropic.claude-opus-4-7",
requestBody: `{"max_tokens":10000,"output_config":{"effort":"high","format":{"type":"json_schema","schema":{"type":"object"}}}}`,
expectEffort: "high",
expectKeptFields: []string{"output_config", "output_config.effort"},
expectRemovedFields: []string{"output_config.format"},
},
}
for _, tc := range tests {
@@ -858,6 +914,13 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
require.True(t, gjson.GetBytes(i.reqPayload, field).Exists(), "%s should be kept", field)
}
effort := gjson.GetBytes(i.reqPayload, "output_config.effort")
if tc.expectEffort == "" {
require.False(t, effort.Exists(), "output_config.effort should not be set")
} else {
require.Equal(t, tc.expectEffort, effort.String())
}
got := clientHeaders.Values("Anthropic-Beta")
require.Equal(t, tc.expectBetaValues, got)
})
+85 -22
View File
@@ -20,6 +20,7 @@ const (
messagesReqPathModel = "model"
messagesReqPathOutputConfig = "output_config"
messagesReqPathOutputConfigEffort = "output_config.effort"
messagesReqPathOutputConfigFormat = "output_config.format"
messagesReqPathMetadata = "metadata"
messagesReqPathServiceTier = "service_tier"
messagesReqPathContainer = "container"
@@ -74,6 +75,9 @@ var (
// If the beta flag is present in the (already-filtered) Anthropic-Beta header,
// the field is kept; otherwise it is stripped. Model-specific beta flags must
// be removed from the header before this check (see filterBedrockBetaFlags).
// Adaptive-only models (Opus 4.7+) are exempt for output_config since they
// support it natively without a beta flag, see
// bedrockModelRequiresAdaptiveThinking.
bedrockBetaGatedFields = map[string]string{
// output_config requires the effort beta (Opus 4.5 only).
messagesReqPathOutputConfig: "effort-2025-11-24",
@@ -324,11 +328,37 @@ func (RequestPayload) resultToRawMessage(items []gjson.Result) []json.RawMessage
return rawMessages
}
// convertAdaptiveThinkingForBedrock converts thinking.type "adaptive" to "enabled" with a calculated budget_tokens
// conversion is needed for Bedrock models that does not support the "adaptive" thinking.type
// The two Bedrock thinking-type conversions below are a temporary shim.
// AI Bridge relays the Anthropic Messages API shape to Bedrock, whose Claude
// models accept a disjoint subset on each generation (older models reject
// "adaptive"; Opus 4.7+ rejects "enabled"). A planned native Bedrock provider
// removes the impedance mismatch and lets us delete this whole block. Hopefully.
// bedrockThinkingEffortRatios maps an output_config.effort hint to the fraction
// of max_tokens to allocate as thinking budget. The mapping is a heuristic
// with no canonical source; ratios adapted from OpenRouter:
// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens#reasoning-effort-level
var bedrockThinkingEffortRatios = map[string]float64{
"low": 0.2,
"medium": 0.5,
"high": 0.8,
"max": 0.95,
}
// bedrockThinkingDefaultEffortRatio is used when output_config.effort is
// absent or unrecognized. Kept as a separate const rather than a runtime
// lookup so a misnamed map key can't silently zero out the budget.
const bedrockThinkingDefaultEffortRatio = 0.8 // matches "high"
// convertAdaptiveThinkingForBedrock converts thinking.type "adaptive" to
// "enabled" with a calculated budget_tokens. Needed for Bedrock models that
// do not support the "adaptive" thinking.type.
//
// This direction has to invent a number, since "enabled" requires budget_tokens.
// We bias the budget by output_config.effort when present, since that's the
// only signal we have about caller intent.
func (p RequestPayload) convertAdaptiveThinkingForBedrock() (RequestPayload, error) {
thinkingType := gjson.GetBytes(p, messagesReqPathThinkingType)
if thinkingType.String() != constAdaptive {
if gjson.GetBytes(p, messagesReqPathThinkingType).String() != constAdaptive {
return p, nil
}
@@ -338,22 +368,9 @@ func (p RequestPayload) convertAdaptiveThinkingForBedrock() (RequestPayload, err
return p, xerrors.New("max_tokens: field required")
}
effort := gjson.GetBytes(p, messagesReqPathOutputConfigEffort).String()
// Enabled thinking type requires budget_tokens set.
// Heuristically calculate value based on the effort level.
// Effort-to-ratio mapping adapted from OpenRouter:
// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens#reasoning-effort-level
var ratio float64
switch effort {
case "low":
ratio = 0.2
case "medium":
ratio = 0.5
case "max":
ratio = 0.95
default: // "high" or absent (high is the default effort)
ratio = 0.8
ratio, ok := bedrockThinkingEffortRatios[gjson.GetBytes(p, messagesReqPathOutputConfigEffort).String()]
if !ok {
ratio = bedrockThinkingDefaultEffortRatio
}
// budget_tokens must be ≥ 1024 && < max_tokens. If the calculated budget
@@ -372,12 +389,54 @@ func (p RequestPayload) convertAdaptiveThinkingForBedrock() (RequestPayload, err
})
}
// convertEnabledThinkingForBedrock rewrites thinking.type "enabled" to plain
// "adaptive", dropping budget_tokens. Needed for Bedrock models that only
// support adaptive thinking (Opus 4.7+).
//
// We deliberately do not derive output_config.effort from the budget. Any
// such mapping would be invented (no canonical budget-to-effort relationship
// exists), and adaptive thinking already has well-defined platform behavior
// when no effort hint is provided. An explicit output_config.effort from the
// caller is preserved naturally because we never touch that field.
//
// See https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-7.html
// and https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-adaptive-thinking.html
func (p RequestPayload) convertEnabledThinkingForBedrock() (RequestPayload, error) {
if gjson.GetBytes(p, messagesReqPathThinkingType).String() != constEnabled {
return p, nil
}
return p.set(messagesReqPathThinking, map[string]string{"type": constAdaptive})
}
// removeBedrockUnsupportedOutputConfigSubFields drops sub-fields of
// output_config that Bedrock rejects even on models where the parent
// output_config object is accepted. Adaptive-only models (Opus 4.7+) accept
// output_config.effort but reject output_config.format (structured outputs)
// with a 400 "Extra inputs are not permitted." The generic field-strip pass
// (removeUnsupportedBedrockFields) operates at top-level granularity only, so
// this targeted pass handles the sub-field case.
func (p RequestPayload) removeBedrockUnsupportedOutputConfigSubFields() (RequestPayload, error) {
if !gjson.GetBytes(p, messagesReqPathOutputConfigFormat).Exists() {
return p, nil
}
out, err := sjson.DeleteBytes(p, messagesReqPathOutputConfigFormat)
if err != nil {
return p, xerrors.Errorf("delete %s: %w", messagesReqPathOutputConfigFormat, err)
}
return RequestPayload(out), nil
}
// removeUnsupportedBedrockFields strips top-level fields that Bedrock does not
// support from the payload. Fields that are gated behind a beta flag are only
// removed when the corresponding flag is absent from the Anthropic-Beta header.
// Model-specific beta flags must already be filtered from the header before
// calling this method (see filterBedrockBetaFlags).
func (p RequestPayload) removeUnsupportedBedrockFields(headers http.Header) (RequestPayload, error) {
//
// Fields exempted by exemptFields are always kept regardless of beta flag
// state. Adaptive-only Bedrock models (Opus 4.7+) require output_config
// without a beta flag, so callers pass the field through this set to bypass
// the effort-2025-11-24 gate.
func (p RequestPayload) removeUnsupportedBedrockFields(headers http.Header, exemptFields ...string) (RequestPayload, error) {
var payloadMap map[string]any
if err := json.Unmarshal(p, &payloadMap); err != nil {
return p, xerrors.Errorf("failed to unmarshal request payload when removing unsupported Bedrock fields: %w", err)
@@ -388,9 +447,13 @@ func (p RequestPayload) removeUnsupportedBedrockFields(headers http.Header) (Req
delete(payloadMap, field)
}
// Strip beta-gated fields only when their beta flag is missing.
// Strip beta-gated fields only when their beta flag is missing and the
// caller has not exempted them for the current model.
betaValues := headers.Values("Anthropic-Beta")
for field, requiredFlag := range bedrockBetaGatedFields {
if slices.Contains(exemptFields, field) {
continue
}
if !slices.Contains(betaValues, requiredFlag) {
delete(payloadMap, field)
}
@@ -361,6 +361,76 @@ func TestRequestPayloadConvertAdaptiveThinkingForBedrock(t *testing.T) {
}
}
func TestRequestPayloadConvertEnabledThinkingForBedrock(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
requestBody string
expectedThinkingType string
// expectedEffort is what output_config.effort should resolve to after
// the conversion. The reverse direction never sets this field itself;
// it only persists when the caller already had it on the payload.
expectedEffort string
}{
{
name: "no_thinking_field_is_no_op",
requestBody: `{"model":"claude-opus-4-7","max_tokens":10000,"messages":[]}`,
},
{
name: "adaptive_thinking_is_no_op",
requestBody: `{"model":"claude-opus-4-7","max_tokens":10000,"thinking":{"type":"adaptive"},"messages":[]}`,
expectedThinkingType: "adaptive",
},
{
name: "disabled_thinking_is_no_op",
requestBody: `{"model":"claude-opus-4-7","max_tokens":10000,"thinking":{"type":"disabled"},"messages":[]}`,
expectedThinkingType: "disabled",
},
{
name: "enabled_with_budget_becomes_adaptive_and_drops_budget",
requestBody: `{"model":"claude-opus-4-7","max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000},"messages":[]}`,
expectedThinkingType: "adaptive",
},
{
name: "enabled_without_budget_becomes_adaptive",
requestBody: `{"model":"claude-opus-4-7","max_tokens":10000,"thinking":{"type":"enabled"},"messages":[]}`,
expectedThinkingType: "adaptive",
},
{
name: "enabled_preserves_explicit_effort",
requestBody: `{"model":"claude-opus-4-7","max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":2000},"output_config":{"effort":"max"},"messages":[]}`,
expectedThinkingType: "adaptive",
expectedEffort: "max",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
payload := mustMessagesPayload(t, tc.requestBody)
updatedPayload, err := payload.convertEnabledThinkingForBedrock()
require.NoError(t, err)
thinking := gjson.GetBytes(updatedPayload, messagesReqPathThinking)
require.NotEqual(t, tc.expectedThinkingType == "", thinking.Exists(), "thinking should not be set")
require.Equal(t, tc.expectedThinkingType, gjson.GetBytes(updatedPayload, messagesReqPathThinkingType).String())
// budget_tokens must always be absent after a successful conversion to adaptive.
budgetTokens := gjson.GetBytes(updatedPayload, messagesReqPathThinkingBudgetTokens)
if tc.expectedThinkingType == "adaptive" {
require.False(t, budgetTokens.Exists(), "budget_tokens should be removed after conversion")
}
effort := gjson.GetBytes(updatedPayload, messagesReqPathOutputConfigEffort)
require.Equal(t, tc.expectedEffort, effort.String())
})
}
}
func TestRequestPayloadDisableParallelToolCalls(t *testing.T) {
t.Parallel()