diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index 8e32dd3a32..3ccb422566 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -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 diff --git a/aibridge/intercept/messages/base_test.go b/aibridge/intercept/messages/base_test.go index 52cdc2ab7b..5696ca2e99 100644 --- a/aibridge/intercept/messages/base_test.go +++ b/aibridge/intercept/messages/base_test.go @@ -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) }) diff --git a/aibridge/intercept/messages/reqpayload.go b/aibridge/intercept/messages/reqpayload.go index 293dca0c7f..12f25758ae 100644 --- a/aibridge/intercept/messages/reqpayload.go +++ b/aibridge/intercept/messages/reqpayload.go @@ -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) } diff --git a/aibridge/intercept/messages/reqpayload_test.go b/aibridge/intercept/messages/reqpayload_test.go index d7cf8ba9b1..29bffaa734 100644 --- a/aibridge/intercept/messages/reqpayload_test.go +++ b/aibridge/intercept/messages/reqpayload_test.go @@ -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()