From 99a1b40afadc981b510099b18d3b710874a2999a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Thu, 19 Mar 2026 20:47:49 +0000 Subject: [PATCH 1/2] fix: filter unsupported Anthropic-Beta headers from Bedrock --- intercept/client_headers.go | 45 +++++++++++- intercept/client_headers_test.go | 46 ++++++++++++ intercept/messages/base.go | 54 +++++++++++++- intercept/messages/base_test.go | 116 ++++++++++++++++++++++--------- intercept/messages/reqpayload.go | 37 ++++++++-- 5 files changed, 256 insertions(+), 42 deletions(-) diff --git a/intercept/client_headers.go b/intercept/client_headers.go index 3e4678f3..b4d162a7 100644 --- a/intercept/client_headers.go +++ b/intercept/client_headers.go @@ -1,6 +1,9 @@ package intercept -import "net/http" +import ( + "net/http" + "strings" +) // hopByHopHeaders are connection-level headers specific to the connection // between client and AI Bridge, not meant for the upstream. @@ -50,6 +53,46 @@ func PrepareClientHeaders(clientHeaders http.Header) http.Header { return prepared } +// bedrockSupportedBetaFlags is the set of Anthropic-Beta flags that AWS Bedrock +// accepts. Flags not in this set cause a 400 "invalid beta flag" error. +// +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html +var bedrockSupportedBetaFlags = map[string]bool{ + "computer-use-2025-01-24": true, + "token-efficient-tools-2025-02-19": true, + "interleaved-thinking-2025-05-14": true, + "output-128k-2025-02-19": true, + "dev-full-thinking-2025-05-14": true, + "context-1m-2025-08-07": true, + "context-management-2025-06-27": true, + "effort-2025-11-24": true, + "tool-search-tool-2025-10-19": true, + "tool-examples-2025-10-29": true, +} + +// FilterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta +// header. The header value is a comma-separated list of flags. +func FilterBedrockBetaFlags(headers http.Header) { + raw := headers.Get("Anthropic-Beta") + if raw == "" { + return + } + + flags := strings.Split(raw, ",") + kept := flags[:0] + for _, flag := range flags { + if bedrockSupportedBetaFlags[strings.TrimSpace(flag)] { + kept = append(kept, strings.TrimSpace(flag)) + } + } + + if len(kept) == 0 { + headers.Del("Anthropic-Beta") + } else { + headers.Set("Anthropic-Beta", strings.Join(kept, ",")) + } +} + // BuildUpstreamHeaders produces the header set for an upstream SDK request. // It starts from the prepared client headers, then preserves specific // headers from the SDK-built request that must not be overwritten. diff --git a/intercept/client_headers_test.go b/intercept/client_headers_test.go index ecd2f018..8b5aecc9 100644 --- a/intercept/client_headers_test.go +++ b/intercept/client_headers_test.go @@ -217,3 +217,49 @@ func TestBuildUpstreamHeaders(t *testing.T) { require.Equal(t, clientCopy, clientHeaders) }) } + +func TestFilterBedrockBetaFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expect string + }{ + { + name: "empty header", + input: "", + expect: "", + }, + { + name: "all supported flags kept", + input: "interleaved-thinking-2025-05-14,effort-2025-11-24", + expect: "interleaved-thinking-2025-05-14,effort-2025-11-24", + }, + { + name: "unsupported flags removed", + input: "claude-code-20250219,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05", + expect: "interleaved-thinking-2025-05-14", + }, + { + name: "header removed when all flags unsupported", + input: "claude-code-20250219,prompt-caching-scope-2026-01-05", + expect: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + headers := http.Header{} + if tc.input != "" { + headers.Set("Anthropic-Beta", tc.input) + } + + FilterBedrockBetaFlags(headers) + + assert.Equal(t, tc.expect, headers.Get("Anthropic-Beta")) + }) + } +} diff --git a/intercept/messages/base.go b/intercept/messages/base.go index f8083024..dbae583c 100644 --- a/intercept/messages/base.go +++ b/intercept/messages/base.go @@ -288,8 +288,15 @@ func (i *interceptionBase) augmentRequestForBedrock() { i.reqPayload = updated } - // Strip fields that Bedrock does not accept. - updated, err = i.reqPayload.removeUnsupportedBedrockFields() + // Filter Anthropic-Beta header to only include Bedrock-supported flags, + // then remove model-specific flags the current model doesn't support. + if i.clientHeaders != nil { + intercept.FilterBedrockBetaFlags(i.clientHeaders) + filterModelGatedBetaFlags(i.clientHeaders, model) + } + + // Strip body fields that Bedrock does not accept. + updated, err = i.reqPayload.removeUnsupportedBedrockFields(i.clientHeaders) if err != nil { i.logger.Warn(context.Background(), "failed to remove unsupported fields for Bedrock", slog.Error(err)) return @@ -305,6 +312,49 @@ func bedrockModelSupportsAdaptiveThinking(model string) bool { strings.Contains(model, "anthropic.claude-sonnet-4-6") } +// bedrockModelGatedBetaFlags maps beta flags to a model predicate. If the model +// does not match, the beta flag is removed from the Anthropic-Beta header so +// that the corresponding body field is also stripped. +// +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html +var bedrockModelGatedBetaFlags = map[string]func(string) bool{ + // output_config with effort is only supported for Opus 4.5 on Bedrock. + "effort-2025-11-24": func(model string) bool { + return strings.HasPrefix(model, "anthropic.claude-opus-4-5") + }, + // context_management is only supported for Sonnet 4.5 and Haiku 4.5 on Bedrock. + "context-management-2025-06-27": func(model string) bool { + return strings.HasPrefix(model, "anthropic.claude-sonnet-4-5") || + strings.HasPrefix(model, "anthropic.claude-haiku-4-5") + }, +} + +// filterModelGatedBetaFlags removes beta flags from the Anthropic-Beta header +// when the current Bedrock model does not support the feature they gate. +func filterModelGatedBetaFlags(headers http.Header, model string) { + raw := headers.Get("Anthropic-Beta") + if raw == "" { + return + } + + flags := strings.Split(raw, ",") + kept := flags[:0] + for _, flag := range flags { + trimmed := strings.TrimSpace(flag) + check, gated := bedrockModelGatedBetaFlags[trimmed] + if gated && !check(model) { + continue + } + kept = append(kept, trimmed) + } + + if len(kept) == 0 { + headers.Del("Anthropic-Beta") + } else { + headers.Set("Anthropic-Beta", strings.Join(kept, ",")) + } +} + // writeUpstreamError marshals and writes a given error. func (i *interceptionBase) writeUpstreamError(w http.ResponseWriter, antErr *ErrorResponse) { if antErr == nil { diff --git a/intercept/messages/base_test.go b/intercept/messages/base_test.go index 723a2b68..09cfc4c3 100644 --- a/intercept/messages/base_test.go +++ b/intercept/messages/base_test.go @@ -2,6 +2,7 @@ package messages import ( "context" + "net/http" "testing" "cdr.dev/slog/v3" @@ -596,13 +597,14 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { tests := []struct { name string - bedrockModel string - requestBody string + bedrockModel string + requestBody string + clientBetaFlags string - expectThinkingType string - // expectBudgetTokens is the exact expected budget_tokens value. - // 0 means budget_tokens should not be present in the output. - expectBudgetTokens int64 + expectThinkingType string + expectBudgetTokens int64 // 0 means budget_tokens should not be present + expectRemovedFields []string + expectBetaHeader string }{ { name: "non_4_6_model_with_adaptive_thinking_gets_converted", @@ -615,56 +617,89 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { name: "non_4_6_model_with_adaptive_thinking_and_small_max_tokens_disables_thinking", bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", requestBody: `{"model":"claude-sonnet-4-5","max_tokens":1000,"thinking":{"type":"adaptive"},"messages":[]}`, - expectThinkingType: "disabled", // 1000 * 0.6 = 600, below 1024 minimum + expectThinkingType: "disabled", }, { name: "opus_4_6_model_with_adaptive_thinking_is_not_converted", bedrockModel: "anthropic.claude-opus-4-6-v1", requestBody: `{"model":"claude-opus-4-6","max_tokens":10000,"thinking":{"type":"adaptive"},"messages":[]}`, expectThinkingType: "adaptive", - expectBudgetTokens: 0, }, { name: "sonnet_4_6_model_with_adaptive_thinking_is_not_converted", bedrockModel: "anthropic.claude-sonnet-4-6", requestBody: `{"model":"claude-sonnet-4-6","max_tokens":10000,"thinking":{"type":"adaptive"},"messages":[]}`, expectThinkingType: "adaptive", - expectBudgetTokens: 0, }, { - name: "non_4_6_model_with_no_thinking_field_is_unchanged", - bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", - requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[]}`, - expectThinkingType: "", - expectBudgetTokens: 0, + name: "non_4_6_model_with_no_thinking_field_is_unchanged", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[]}`, }, { name: "non_4_6_model_with_enabled_thinking_is_unchanged", bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000},"messages":[]}`, expectThinkingType: "enabled", - expectBudgetTokens: 5000, // already set, not recalculated + expectBudgetTokens: 5000, }, { - name: "non_4_6_model_with_output_config_strips_it_and_uses_effort_for_budget", - bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", - requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},"messages":[]}`, - expectThinkingType: "enabled", - expectBudgetTokens: 2000, // 10000 * 0.2 (low effort) + name: "output_config_stripped_without_beta_flag_and_effort_used_for_budget", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},"messages":[]}`, + expectThinkingType: "enabled", + expectBudgetTokens: 2000, // 10000 * 0.2 (low effort) + expectRemovedFields: []string{"output_config"}, }, { - name: "4_6_model_with_output_config_strips_it", - bedrockModel: "anthropic.claude-opus-4-6-v1", - requestBody: `{"model":"claude-opus-4-6","max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"messages":[]}`, - expectThinkingType: "adaptive", - expectBudgetTokens: 0, + name: "output_config_kept_when_effort_beta_flag_present_on_opus_4_5", + bedrockModel: "anthropic.claude-opus-4-5-20250929-v1:0", + clientBetaFlags: "effort-2025-11-24,interleaved-thinking-2025-05-14", + requestBody: `{"model":"claude-opus-4-5","max_tokens":10000,"messages":[],"output_config":{"effort":"high"}}`, + expectBetaHeader: "effort-2025-11-24,interleaved-thinking-2025-05-14", }, { - name: "all_unsupported_fields_are_stripped", - bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", - requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[],"output_config":{"effort":"high"},"metadata":{"user_id":"u123"},"service_tier":"auto","container":"ctr_abc","inference_geo":"us","context_management":{"type":"auto"}}`, - expectThinkingType: "", - expectBudgetTokens: 0, + name: "output_config_stripped_for_non_opus_4_5_even_with_effort_beta_flag", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "effort-2025-11-24,interleaved-thinking-2025-05-14", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[],"output_config":{"effort":"high"}}`, + expectRemovedFields: []string{"output_config"}, + expectBetaHeader: "interleaved-thinking-2025-05-14", + }, + { + name: "context_management_kept_when_beta_flag_present", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "context-management-2025-06-27", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[],"context_management":{"type":"auto"}}`, + expectBetaHeader: "context-management-2025-06-27", + }, + { + name: "context_management_stripped_without_beta_flag", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[],"context_management":{"type":"auto"}}`, + expectRemovedFields: []string{"context_management"}, + }, + { + name: "context_management_stripped_for_unsupported_model_even_with_beta_flag", + bedrockModel: "anthropic.claude-opus-4-6-v1", + clientBetaFlags: "context-management-2025-06-27", + requestBody: `{"model":"claude-opus-4-6","max_tokens":10000,"thinking":{"type":"adaptive"},"messages":[],"context_management":{"type":"auto"}}`, + expectThinkingType: "adaptive", + expectRemovedFields: []string{"context_management"}, + }, + { + name: "unsupported_beta_flags_are_filtered_out", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "claude-code-20250219,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[]}`, + expectBetaHeader: "interleaved-thinking-2025-05-14", + }, + { + name: "all_unsupported_fields_stripped_and_beta_flags_filtered", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "claude-code-20250219,prompt-caching-scope-2026-01-05", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[],"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"}, }, } @@ -672,13 +707,21 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + var clientHeaders http.Header + if tc.clientBetaFlags != "" { + clientHeaders = http.Header{ + "Anthropic-Beta": {tc.clientBetaFlags}, + } + } + i := &interceptionBase{ reqPayload: mustMessagesPayload(t, tc.requestBody), bedrockCfg: &config.AWSBedrock{ Model: tc.bedrockModel, SmallFastModel: "anthropic.claude-haiku-3-5", }, - logger: slog.Make(), + clientHeaders: clientHeaders, + logger: slog.Make(), } i.augmentRequestForBedrock() @@ -700,9 +743,14 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { // Model should always be set to the bedrock model. require.Equal(t, tc.bedrockModel, gjson.GetBytes(i.reqPayload, "model").String()) - // Unsupported fields should always be stripped for Bedrock. - for _, field := range bedrockUnsupportedFields { - require.False(t, gjson.GetBytes(i.reqPayload, field).Exists(), "%s should be removed for Bedrock", field) + // Verify expected fields are removed. + for _, field := range tc.expectRemovedFields { + require.False(t, gjson.GetBytes(i.reqPayload, field).Exists(), "%s should be removed", field) + } + + // Verify beta header filtering. + if clientHeaders != nil { + require.Equal(t, tc.expectBetaHeader, clientHeaders.Get("Anthropic-Beta")) } }) } diff --git a/intercept/messages/reqpayload.go b/intercept/messages/reqpayload.go index 2cba26b3..06b05caa 100644 --- a/intercept/messages/reqpayload.go +++ b/intercept/messages/reqpayload.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "net/http" + "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/shared/constant" @@ -62,12 +64,19 @@ var ( // Anthropic API fields: https://platform.claude.com/docs/en/api/messages/create // Bedrock request body: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html bedrockUnsupportedFields = []string{ - messagesReqPathOutputConfig, // requires beta header 'effort-2025-11-24' messagesReqPathMetadata, messagesReqPathServiceTier, messagesReqPathContainer, messagesReqPathInferenceGeo, - messagesReqPathContextManagement, + } + + // bedrockBetaGatedFields maps body fields to the beta flag that enables them. + // 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 filterModelGatedBetaFlags). + bedrockBetaGatedFields = map[string]string{ + messagesReqPathOutputConfig: "effort-2025-11-24", + messagesReqPathContextManagement: "context-management-2025-06-27", } ) @@ -342,10 +351,15 @@ func (p MessagesRequestPayload) convertAdaptiveThinkingForBedrock() (MessagesReq }) } -// removeUnsupportedBedrockFields strips all top-level fields that Bedrock does -// not support from the payload. -func (p MessagesRequestPayload) removeUnsupportedBedrockFields() (MessagesRequestPayload, error) { +// 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 filterModelGatedBetaFlags). +func (p MessagesRequestPayload) removeUnsupportedBedrockFields(headers http.Header) (MessagesRequestPayload, error) { result := p + + // Always strip unconditionally unsupported fields. for _, field := range bedrockUnsupportedFields { var err error result, err = result.delete(field) @@ -353,6 +367,19 @@ func (p MessagesRequestPayload) removeUnsupportedBedrockFields() (MessagesReques return p, fmt.Errorf("removing %q: %w", field, err) } } + + // Strip beta-gated fields only when their beta flag is missing. + betaHeader := headers.Get("Anthropic-Beta") + for field, requiredFlag := range bedrockBetaGatedFields { + if !strings.Contains(betaHeader, requiredFlag) { + var err error + result, err = result.delete(field) + if err != nil { + return p, fmt.Errorf("removing %q: %w", field, err) + } + } + } + return result, nil } From c4cdef288955a5cae08e94b570a3b5cf3d6f157a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Thu, 19 Mar 2026 21:54:56 +0000 Subject: [PATCH 2/2] chore: add Bedrock request augmentation integration test --- fixtures/anthropic/simple_bedrock.txtar | 51 +++++++++ fixtures/fixtures.go | 3 + intercept/client_headers.go | 41 -------- intercept/client_headers_test.go | 46 --------- intercept/messages/base.go | 71 +++++++------ intercept/messages/base_test.go | 81 +++++++++++++++ internal/integrationtest/bridge_test.go | 131 ++++++++++++++++++++++++ 7 files changed, 307 insertions(+), 117 deletions(-) create mode 100644 fixtures/anthropic/simple_bedrock.txtar diff --git a/fixtures/anthropic/simple_bedrock.txtar b/fixtures/anthropic/simple_bedrock.txtar new file mode 100644 index 00000000..45979381 --- /dev/null +++ b/fixtures/anthropic/simple_bedrock.txtar @@ -0,0 +1,51 @@ +Simple Bedrock request. Tests that fields unsupported by Bedrock are removed +and adaptive thinking is converted to enabled with a budget. Includes all +bedrockUnsupportedFields (metadata, service_tier, container, inference_geo) +and beta-gated fields (output_config, context_management). + +-- request -- +{ + "model": "claude-sonnet-4-6", + "max_tokens": 32000, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello." + } + ] + } + ], + "thinking": {"type": "adaptive"}, + "metadata": {"user_id": "session_abc123"}, + "service_tier": "auto", + "container": {"type": "ephemeral"}, + "inference_geo": {"allow": ["us"]}, + "output_config": {"effort": "medium"}, + "context_management": {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}, + "stream": true +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_bdrk_01Test","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":4}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello! How can I help?"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":10}} + +event: message_stop +data: {"type":"message_stop"} + +-- non-streaming -- +{"id":"msg_bdrk_01Test","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[{"type":"text","text":"Hello! How can I help?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":10}} diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go index 8aaeef15..1a5d9515 100644 --- a/fixtures/fixtures.go +++ b/fixtures/fixtures.go @@ -32,6 +32,9 @@ var ( //go:embed anthropic/non_stream_error.txtar AntNonStreamError []byte + + //go:embed anthropic/simple_bedrock.txtar + AntSimpleBedrock []byte ) var ( diff --git a/intercept/client_headers.go b/intercept/client_headers.go index b4d162a7..8d4b2def 100644 --- a/intercept/client_headers.go +++ b/intercept/client_headers.go @@ -2,7 +2,6 @@ package intercept import ( "net/http" - "strings" ) // hopByHopHeaders are connection-level headers specific to the connection @@ -53,46 +52,6 @@ func PrepareClientHeaders(clientHeaders http.Header) http.Header { return prepared } -// bedrockSupportedBetaFlags is the set of Anthropic-Beta flags that AWS Bedrock -// accepts. Flags not in this set cause a 400 "invalid beta flag" error. -// -// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html -var bedrockSupportedBetaFlags = map[string]bool{ - "computer-use-2025-01-24": true, - "token-efficient-tools-2025-02-19": true, - "interleaved-thinking-2025-05-14": true, - "output-128k-2025-02-19": true, - "dev-full-thinking-2025-05-14": true, - "context-1m-2025-08-07": true, - "context-management-2025-06-27": true, - "effort-2025-11-24": true, - "tool-search-tool-2025-10-19": true, - "tool-examples-2025-10-29": true, -} - -// FilterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta -// header. The header value is a comma-separated list of flags. -func FilterBedrockBetaFlags(headers http.Header) { - raw := headers.Get("Anthropic-Beta") - if raw == "" { - return - } - - flags := strings.Split(raw, ",") - kept := flags[:0] - for _, flag := range flags { - if bedrockSupportedBetaFlags[strings.TrimSpace(flag)] { - kept = append(kept, strings.TrimSpace(flag)) - } - } - - if len(kept) == 0 { - headers.Del("Anthropic-Beta") - } else { - headers.Set("Anthropic-Beta", strings.Join(kept, ",")) - } -} - // BuildUpstreamHeaders produces the header set for an upstream SDK request. // It starts from the prepared client headers, then preserves specific // headers from the SDK-built request that must not be overwritten. diff --git a/intercept/client_headers_test.go b/intercept/client_headers_test.go index 8b5aecc9..ecd2f018 100644 --- a/intercept/client_headers_test.go +++ b/intercept/client_headers_test.go @@ -217,49 +217,3 @@ func TestBuildUpstreamHeaders(t *testing.T) { require.Equal(t, clientCopy, clientHeaders) }) } - -func TestFilterBedrockBetaFlags(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - expect string - }{ - { - name: "empty header", - input: "", - expect: "", - }, - { - name: "all supported flags kept", - input: "interleaved-thinking-2025-05-14,effort-2025-11-24", - expect: "interleaved-thinking-2025-05-14,effort-2025-11-24", - }, - { - name: "unsupported flags removed", - input: "claude-code-20250219,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05", - expect: "interleaved-thinking-2025-05-14", - }, - { - name: "header removed when all flags unsupported", - input: "claude-code-20250219,prompt-caching-scope-2026-01-05", - expect: "", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - headers := http.Header{} - if tc.input != "" { - headers.Set("Anthropic-Beta", tc.input) - } - - FilterBedrockBetaFlags(headers) - - assert.Equal(t, tc.expect, headers.Get("Anthropic-Beta")) - }) - } -} diff --git a/intercept/messages/base.go b/intercept/messages/base.go index dbae583c..3266fcba 100644 --- a/intercept/messages/base.go +++ b/intercept/messages/base.go @@ -32,6 +32,23 @@ import ( "cdr.dev/slog/v3" ) +// bedrockSupportedBetaFlags is the set of Anthropic-Beta flags that AWS Bedrock +// accepts. Flags not in this set cause a 400 "invalid beta flag" error. +// +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html +var bedrockSupportedBetaFlags = map[string]bool{ + "computer-use-2025-01-24": true, + "token-efficient-tools-2025-02-19": true, + "interleaved-thinking-2025-05-14": true, + "output-128k-2025-02-19": true, + "dev-full-thinking-2025-05-14": true, + "context-1m-2025-08-07": true, + "context-management-2025-06-27": true, + "effort-2025-11-24": true, + "tool-search-tool-2025-10-19": true, + "tool-examples-2025-10-29": true, +} + type interceptionBase struct { id uuid.UUID reqPayload MessagesRequestPayload @@ -288,11 +305,10 @@ func (i *interceptionBase) augmentRequestForBedrock() { i.reqPayload = updated } - // Filter Anthropic-Beta header to only include Bedrock-supported flags, - // then remove model-specific flags the current model doesn't support. + // Filter Anthropic-Beta header to only include Bedrock-supported flags + // that the current model supports. if i.clientHeaders != nil { - intercept.FilterBedrockBetaFlags(i.clientHeaders) - filterModelGatedBetaFlags(i.clientHeaders, model) + filterBedrockBetaFlags(i.clientHeaders, model) } // Strip body fields that Bedrock does not accept. @@ -312,46 +328,41 @@ func bedrockModelSupportsAdaptiveThinking(model string) bool { strings.Contains(model, "anthropic.claude-sonnet-4-6") } -// bedrockModelGatedBetaFlags maps beta flags to a model predicate. If the model -// does not match, the beta flag is removed from the Anthropic-Beta header so -// that the corresponding body field is also stripped. -// -// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html -var bedrockModelGatedBetaFlags = map[string]func(string) bool{ - // output_config with effort is only supported for Opus 4.5 on Bedrock. - "effort-2025-11-24": func(model string) bool { - return strings.HasPrefix(model, "anthropic.claude-opus-4-5") - }, - // context_management is only supported for Sonnet 4.5 and Haiku 4.5 on Bedrock. - "context-management-2025-06-27": func(model string) bool { - return strings.HasPrefix(model, "anthropic.claude-sonnet-4-5") || - strings.HasPrefix(model, "anthropic.claude-haiku-4-5") - }, -} - -// filterModelGatedBetaFlags removes beta flags from the Anthropic-Beta header -// when the current Bedrock model does not support the feature they gate. -func filterModelGatedBetaFlags(headers http.Header, model string) { +// filterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta +// header and also removes model-gated flags the current model doesn't support. +func filterBedrockBetaFlags(headers http.Header, model string) { raw := headers.Get("Anthropic-Beta") if raw == "" { return } flags := strings.Split(raw, ",") - kept := flags[:0] + var keep []string for _, flag := range flags { trimmed := strings.TrimSpace(flag) - check, gated := bedrockModelGatedBetaFlags[trimmed] - if gated && !check(model) { + if !bedrockSupportedBetaFlags[trimmed] { + continue + } + + // effort is only supported for Opus 4.5 on Bedrock. + if trimmed == "effort-2025-11-24" && !strings.Contains(model, "anthropic.claude-opus-4-5") { continue } - kept = append(kept, trimmed) + + // context_management is only supported for Sonnet 4.5 and Haiku 4.5 on Bedrock. + if trimmed == "context-management-2025-06-27" && + !strings.Contains(model, "anthropic.claude-sonnet-4-5") && + !strings.Contains(model, "anthropic.claude-haiku-4-5") { + continue + } + + keep = append(keep, trimmed) } - if len(kept) == 0 { + if len(keep) == 0 { headers.Del("Anthropic-Beta") } else { - headers.Set("Anthropic-Beta", strings.Join(kept, ",")) + headers.Set("Anthropic-Beta", strings.Join(keep, ",")) } } diff --git a/intercept/messages/base_test.go b/intercept/messages/base_test.go index 09cfc4c3..1c1ed412 100644 --- a/intercept/messages/base_test.go +++ b/intercept/messages/base_test.go @@ -794,3 +794,84 @@ func (m *mockServerProxier) GetTool(id string) *mcp.Tool { func (m *mockServerProxier) CallTool(context.Context, string, any) (*mcpgo.CallToolResult, error) { return nil, nil } + +func TestFilterBedrockBetaFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + input string + expect string + }{ + { + name: "empty header", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + input: "", + expect: "", + }, + { + name: "all supported flags kept", + model: "anthropic.claude-opus-4-5-20250929-v1:0", + input: "interleaved-thinking-2025-05-14,effort-2025-11-24", + expect: "interleaved-thinking-2025-05-14,effort-2025-11-24", + }, + { + name: "unsupported flags removed", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + input: "claude-code-20250219,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05", + expect: "interleaved-thinking-2025-05-14", + }, + { + name: "header removed when all flags unsupported", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + input: "claude-code-20250219,prompt-caching-scope-2026-01-05", + expect: "", + }, + { + name: "effort flag removed for non opus 4.5 model", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + input: "effort-2025-11-24,interleaved-thinking-2025-05-14", + expect: "interleaved-thinking-2025-05-14", + }, + { + name: "effort flag kept for opus 4.5 model", + model: "anthropic.claude-opus-4-5-20250929-v1:0", + input: "effort-2025-11-24,interleaved-thinking-2025-05-14", + expect: "effort-2025-11-24,interleaved-thinking-2025-05-14", + }, + { + name: "context management kept for sonnet 4.5", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + input: "context-management-2025-06-27", + expect: "context-management-2025-06-27", + }, + { + name: "context management kept for haiku 4.5", + model: "anthropic.claude-haiku-4-5-20250929-v1:0", + input: "context-management-2025-06-27", + expect: "context-management-2025-06-27", + }, + { + name: "context management removed for unsupported model", + model: "anthropic.claude-opus-4-6-v1", + input: "context-management-2025-06-27,interleaved-thinking-2025-05-14", + expect: "interleaved-thinking-2025-05-14", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + headers := http.Header{} + if tc.input != "" { + headers.Set("Anthropic-Beta", tc.input) + } + + filterBedrockBetaFlags(headers, tc.model) + + require.Equal(t, tc.expect, headers.Get("Anthropic-Beta")) + }) + } +} diff --git a/internal/integrationtest/bridge_test.go b/internal/integrationtest/bridge_test.go index a2a746e3..50d02dd1 100644 --- a/internal/integrationtest/bridge_test.go +++ b/internal/integrationtest/bridge_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "testing" "time" @@ -315,6 +316,136 @@ func TestAWSBedrockIntegration(t *testing.T) { }) } }) + + // Tests that Bedrock-incompatible fields are stripped and adaptive thinking + // is handled correctly per model. Different Bedrock model names trigger + // different behavior for beta flag filtering and field stripping. + t.Run("unsupported fields removed", func(t *testing.T) { + t.Parallel() + + // All fields in the fixture request that Bedrock may strip. Fields + // listed in a test case's expectKeptFields survive; all others must + // be absent from the forwarded body. + strippableFields := []string{ + "metadata", "service_tier", "container", "inference_geo", // always stripped + "output_config", "context_management", // stripped unless their beta flag survives + } + + cases := []struct { + name string + model string + smallFastModel string + expectThinkingType string + expectBudgetTokens int64 // 0 means budget_tokens should not be present + expectKeptFields []string // fields from strippableFields expected to survive + expectedBetaFlags []string // values expected in the anthropic_beta array in the forwarded body + }{ + // "beddel" matches no model prefix, so adaptive thinking is converted + // to enabled with budget, and all model-gated beta flags are stripped. + { + name: "beddel", + model: "beddel", + smallFastModel: "modrock", + expectThinkingType: "enabled", + expectBudgetTokens: 16000, // 32000 * 0.5 (medium effort) + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"}, + }, + // Opus 4.5 supports the effort beta, so output_config is kept. + { + name: "opus-4.5", + model: "anthropic.claude-opus-4-5-20250514-v1:0", + smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectThinkingType: "enabled", + expectBudgetTokens: 16000, + expectKeptFields: []string{"output_config"}, + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14", "effort-2025-11-24"}, + }, + // Sonnet 4.5 supports context-management beta, so context_management is kept. + { + name: "sonnet-4.5", + model: "anthropic.claude-sonnet-4-5-20241022-v2:0", + smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectThinkingType: "enabled", + expectBudgetTokens: 16000, + expectKeptFields: []string{"context_management"}, + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14", "context-management-2025-06-27"}, + }, + // Opus 4.6 supports adaptive thinking natively, so it is kept as-is. + // Neither effort nor context-management betas apply to this model. + { + name: "opus-4.6", + model: "anthropic.claude-opus-4-6-20260619-v1:0", + smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectThinkingType: "adaptive", + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"}, + }, + } + + for _, tc := range cases { + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*30) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.AntSimpleBedrock) + upstream := newMockUpstream(t, ctx, newFixtureResponse(fix)) + + bCfg := &config.AWSBedrock{ + Region: "us-west-2", + AccessKey: "test-access-key", + AccessKeySecret: "test-secret-key", + Model: tc.model, + SmallFastModel: tc.smallFastModel, + BaseURL: upstream.URL, + } + + bridgeServer := newBridgeTestServer(t, ctx, upstream.URL, + withCustomProvider(provider.NewAnthropic(anthropicCfg(upstream.URL, apiKey), bCfg)), + ) + + reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming) + require.NoError(t, err) + + // Send with Anthropic-Beta header containing flags that should be filtered. + resp := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, http.Header{ + "Anthropic-Beta": {"interleaved-thinking-2025-05-14,effort-2025-11-24,context-management-2025-06-27,prompt-caching-scope-2026-01-05"}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + received := upstream.receivedRequests() + require.Len(t, received, 1) + body := received[0].Body + + // Verify strippable fields: kept only if listed in expectKeptFields. + for _, field := range strippableFields { + assert.Equal(t, slices.Contains(tc.expectKeptFields, field), gjson.GetBytes(body, field).Exists(), "field %s", field) + } + + // Verify thinking behavior. + assert.Equal(t, tc.expectThinkingType, gjson.GetBytes(body, "thinking.type").String(), "thinking type mismatch") + if tc.expectBudgetTokens > 0 { + assert.Equal(t, tc.expectBudgetTokens, gjson.GetBytes(body, "thinking.budget_tokens").Int(), "budget_tokens mismatch") + } else { + assert.False(t, gjson.GetBytes(body, "thinking.budget_tokens").Exists(), "budget_tokens should not be present") + } + + // The Bedrock SDK middleware moves Anthropic-Beta from the header + // into the body as "anthropic_beta". The SDK encodes the + // comma-separated header value as a single array element. + betaArr := gjson.GetBytes(body, "anthropic_beta").Array() + require.Len(t, betaArr, 1, "expected single anthropic_beta element") + gotFlags := strings.Split(betaArr[0].String(), ",") + assert.Equal(t, tc.expectedBetaFlags, gotFlags, "beta flags mismatch") + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + } + }) } func TestOpenAIChatCompletions(t *testing.T) {