Skip to content

Commit 0ef176a

Browse files
authored
Merge pull request #1352 from mfenderov/feature/bedrock-interleaved-thinking
feat(bedrock): add interleaved thinking support
2 parents e76ae4c + d13d6c3 commit 0ef176a

5 files changed

Lines changed: 293 additions & 5 deletions

File tree

examples/pr-reviewer-bedrock.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,8 @@ models:
457457
provider: amazon-bedrock
458458
model: global.anthropic.claude-opus-4-5-20251101-v1:0
459459
max_tokens: 64000
460+
thinking_budget: 32000
460461
provider_opts:
461462
region: eu-west-1
462463
profile: bedrock1
464+
interleaved_thinking: true

pkg/model/provider/bedrock/adapter.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,16 @@ func (a *streamAdapter) Recv() (chat.MessageStreamResponse, error) {
151151
}}
152152

153153
case *types.ContentBlockDeltaMemberReasoningContent:
154-
// Handle reasoning/thinking content
155-
if textDelta, ok := delta.Value.(*types.ReasoningContentBlockDeltaMemberText); ok {
156-
response.Choices[0].Delta.ReasoningContent = textDelta.Value
154+
// Handle reasoning content (text, signature, redacted)
155+
switch reasoningDelta := delta.Value.(type) {
156+
case *types.ReasoningContentBlockDeltaMemberText:
157+
response.Choices[0].Delta.ReasoningContent = reasoningDelta.Value
158+
case *types.ReasoningContentBlockDeltaMemberSignature:
159+
response.Choices[0].Delta.ThinkingSignature = reasoningDelta.Value
160+
case *types.ReasoningContentBlockDeltaMemberRedactedContent:
161+
response.Choices[0].Delta.ThinkingSignature = string(reasoningDelta.Value)
162+
default:
163+
return chat.MessageStreamResponse{}, fmt.Errorf("unknown reasoning delta type: %T", reasoningDelta)
157164
}
158165
}
159166
}

pkg/model/provider/bedrock/client.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ func (c *Client) isThinkingEnabled() bool {
269269
return true
270270
}
271271

272+
// interleavedThinkingEnabled returns true when provider_opts.interleaved_thinking is set.
273+
func (c *Client) interleavedThinkingEnabled() bool {
274+
return getProviderOpt[bool](c.ModelConfig.ProviderOpts, "interleaved_thinking")
275+
}
276+
272277
// buildAdditionalModelRequestFields creates model-specific parameters.
273278
// Used for extended thinking (reasoning) configuration on Claude models.
274279
func (c *Client) buildAdditionalModelRequestFields() document.Interface {
@@ -295,12 +300,20 @@ func (c *Client) buildAdditionalModelRequestFields() document.Interface {
295300

296301
slog.Debug("Bedrock request using thinking_budget", "budget_tokens", tokens)
297302

298-
return document.NewLazyDocument(map[string]any{
303+
fields := map[string]any{
299304
"thinking": map[string]any{
300305
"type": "enabled",
301306
"budget_tokens": tokens,
302307
},
303-
})
308+
}
309+
310+
// Add anthropic_beta field for interleaved thinking
311+
if c.interleavedThinkingEnabled() {
312+
fields["anthropic_beta"] = []string{"interleaved-thinking-2025-05-14"}
313+
slog.Debug("Bedrock request using interleaved thinking beta")
314+
}
315+
316+
return document.NewLazyDocument(fields)
304317
}
305318

306319
// getProviderOpt extracts a typed value from provider_opts

pkg/model/provider/bedrock/client_test.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,3 +827,250 @@ func TestBuildInferenceConfig_SetsTempTopPWhenThinkingBudgetInvalid(t *testing.T
827827
require.NotNil(t, cfg.TopP)
828828
assert.InDelta(t, 0.9, *cfg.TopP, 0.01)
829829
}
830+
831+
func TestInterleavedThinkingEnabled_True(t *testing.T) {
832+
t.Parallel()
833+
834+
client := &Client{
835+
Config: base.Config{
836+
ModelConfig: latest.ModelConfig{
837+
Provider: "amazon-bedrock",
838+
Model: "anthropic.claude-sonnet-4-20250514-v1:0",
839+
ProviderOpts: map[string]any{
840+
"interleaved_thinking": true,
841+
},
842+
},
843+
},
844+
}
845+
846+
assert.True(t, client.interleavedThinkingEnabled())
847+
}
848+
849+
func TestInterleavedThinkingEnabled_False(t *testing.T) {
850+
t.Parallel()
851+
852+
client := &Client{
853+
Config: base.Config{
854+
ModelConfig: latest.ModelConfig{
855+
Provider: "amazon-bedrock",
856+
Model: "anthropic.claude-sonnet-4-20250514-v1:0",
857+
ProviderOpts: map[string]any{
858+
"interleaved_thinking": false,
859+
},
860+
},
861+
},
862+
}
863+
864+
assert.False(t, client.interleavedThinkingEnabled())
865+
}
866+
867+
func TestInterleavedThinkingEnabled_NotSet(t *testing.T) {
868+
t.Parallel()
869+
870+
client := &Client{
871+
Config: base.Config{
872+
ModelConfig: latest.ModelConfig{
873+
Provider: "amazon-bedrock",
874+
Model: "anthropic.claude-sonnet-4-20250514-v1:0",
875+
ProviderOpts: map[string]any{},
876+
},
877+
},
878+
}
879+
880+
assert.False(t, client.interleavedThinkingEnabled())
881+
}
882+
883+
func TestInterleavedThinkingEnabled_NilProviderOpts(t *testing.T) {
884+
t.Parallel()
885+
886+
client := &Client{
887+
Config: base.Config{
888+
ModelConfig: latest.ModelConfig{
889+
Provider: "amazon-bedrock",
890+
Model: "anthropic.claude-sonnet-4-20250514-v1:0",
891+
ProviderOpts: nil,
892+
},
893+
},
894+
}
895+
896+
assert.False(t, client.interleavedThinkingEnabled())
897+
}
898+
899+
func TestBuildAdditionalModelRequestFields_WithInterleavedThinking(t *testing.T) {
900+
t.Parallel()
901+
902+
maxTokens := int64(64000)
903+
client := &Client{
904+
Config: base.Config{
905+
ModelConfig: latest.ModelConfig{
906+
Provider: "amazon-bedrock",
907+
Model: "anthropic.claude-sonnet-4-20250514-v1:0",
908+
MaxTokens: &maxTokens,
909+
ThinkingBudget: &latest.ThinkingBudget{
910+
Tokens: 16384,
911+
},
912+
ProviderOpts: map[string]any{
913+
"interleaved_thinking": true,
914+
},
915+
},
916+
},
917+
}
918+
919+
result := client.buildAdditionalModelRequestFields()
920+
921+
require.NotNil(t, result, "expected document for valid thinking_budget with interleaved thinking")
922+
// The document contains anthropic_beta when interleaved thinking is enabled
923+
// We can't easily inspect the lazy document contents, but we verify it's not nil
924+
}
925+
926+
func TestBuildAdditionalModelRequestFields_WithoutInterleavedThinking(t *testing.T) {
927+
t.Parallel()
928+
929+
maxTokens := int64(64000)
930+
client := &Client{
931+
Config: base.Config{
932+
ModelConfig: latest.ModelConfig{
933+
Provider: "amazon-bedrock",
934+
Model: "anthropic.claude-sonnet-4-20250514-v1:0",
935+
MaxTokens: &maxTokens,
936+
ThinkingBudget: &latest.ThinkingBudget{
937+
Tokens: 16384,
938+
},
939+
// No interleaved_thinking in provider_opts
940+
ProviderOpts: map[string]any{},
941+
},
942+
},
943+
}
944+
945+
result := client.buildAdditionalModelRequestFields()
946+
947+
require.NotNil(t, result, "expected document for valid thinking_budget")
948+
// Without interleaved thinking, no anthropic_beta header should be added
949+
// Basic thinking still works - this tests backward compatibility
950+
}
951+
952+
func TestConvertAssistantContent_WithThinkingBlocks(t *testing.T) {
953+
t.Parallel()
954+
955+
msg := &chat.Message{
956+
Role: chat.MessageRoleAssistant,
957+
Content: "Here's my answer",
958+
ReasoningContent: "Let me think about this...",
959+
ThinkingSignature: "sig_abc123",
960+
}
961+
962+
blocks := convertAssistantContent(msg)
963+
964+
// Should have thinking block first, then text block
965+
require.Len(t, blocks, 2)
966+
967+
// First block should be reasoning content
968+
reasoningBlock, ok := blocks[0].(*types.ContentBlockMemberReasoningContent)
969+
require.True(t, ok, "first block should be reasoning content")
970+
971+
// Verify the reasoning content structure
972+
reasoningText, ok := reasoningBlock.Value.(*types.ReasoningContentBlockMemberReasoningText)
973+
require.True(t, ok, "reasoning value should be ReasoningText")
974+
assert.Equal(t, "Let me think about this...", *reasoningText.Value.Text)
975+
assert.Equal(t, "sig_abc123", *reasoningText.Value.Signature)
976+
977+
// Second block should be text content
978+
textBlock, ok := blocks[1].(*types.ContentBlockMemberText)
979+
require.True(t, ok, "second block should be text content")
980+
assert.Equal(t, "Here's my answer", textBlock.Value)
981+
}
982+
983+
func TestConvertAssistantContent_WithoutThinkingBlocks(t *testing.T) {
984+
t.Parallel()
985+
986+
msg := &chat.Message{
987+
Role: chat.MessageRoleAssistant,
988+
Content: "Here's my answer",
989+
// No ReasoningContent or ThinkingSignature
990+
}
991+
992+
blocks := convertAssistantContent(msg)
993+
994+
// Should only have text block
995+
require.Len(t, blocks, 1)
996+
997+
textBlock, ok := blocks[0].(*types.ContentBlockMemberText)
998+
require.True(t, ok)
999+
assert.Equal(t, "Here's my answer", textBlock.Value)
1000+
}
1001+
1002+
func TestConvertAssistantContent_MissingSignature(t *testing.T) {
1003+
t.Parallel()
1004+
1005+
// When only ReasoningContent is present but no signature,
1006+
// thinking block should NOT be included (signature required for multi-turn)
1007+
msg := &chat.Message{
1008+
Role: chat.MessageRoleAssistant,
1009+
Content: "Here's my answer",
1010+
ReasoningContent: "Let me think...",
1011+
ThinkingSignature: "", // Missing signature
1012+
}
1013+
1014+
blocks := convertAssistantContent(msg)
1015+
1016+
// Should only have text block (no thinking block without signature)
1017+
require.Len(t, blocks, 1)
1018+
1019+
textBlock, ok := blocks[0].(*types.ContentBlockMemberText)
1020+
require.True(t, ok)
1021+
assert.Equal(t, "Here's my answer", textBlock.Value)
1022+
}
1023+
1024+
func TestConvertAssistantContent_RedactedThinking(t *testing.T) {
1025+
t.Parallel()
1026+
1027+
// When only signature is present but no reasoning content,
1028+
// a redacted thinking block should be included to maintain
1029+
// conversation integrity for multi-turn extended thinking.
1030+
msg := &chat.Message{
1031+
Role: chat.MessageRoleAssistant,
1032+
Content: "Here's my answer",
1033+
ReasoningContent: "", // Content redacted for safety
1034+
ThinkingSignature: "sig_abc123",
1035+
}
1036+
1037+
blocks := convertAssistantContent(msg)
1038+
1039+
// Should have redacted thinking block first, then text block
1040+
require.Len(t, blocks, 2)
1041+
1042+
// First block should be redacted reasoning content
1043+
reasoningBlock, ok := blocks[0].(*types.ContentBlockMemberReasoningContent)
1044+
require.True(t, ok, "first block should be ContentBlockMemberReasoningContent")
1045+
1046+
redactedContent, ok := reasoningBlock.Value.(*types.ReasoningContentBlockMemberRedactedContent)
1047+
require.True(t, ok, "reasoning block should be ReasoningContentBlockMemberRedactedContent")
1048+
assert.Equal(t, []byte("sig_abc123"), redactedContent.Value)
1049+
1050+
// Second block should be text
1051+
textBlock, ok := blocks[1].(*types.ContentBlockMemberText)
1052+
require.True(t, ok)
1053+
assert.Equal(t, "Here's my answer", textBlock.Value)
1054+
}
1055+
1056+
func TestConvertAssistantContent_NoThinkingWhenBothEmpty(t *testing.T) {
1057+
t.Parallel()
1058+
1059+
// When neither reasoning content nor signature is present,
1060+
// no thinking blocks should be included
1061+
msg := &chat.Message{
1062+
Role: chat.MessageRoleAssistant,
1063+
Content: "Here's my answer",
1064+
ReasoningContent: "",
1065+
ThinkingSignature: "",
1066+
}
1067+
1068+
blocks := convertAssistantContent(msg)
1069+
1070+
// Should only have text block
1071+
require.Len(t, blocks, 1)
1072+
1073+
textBlock, ok := blocks[0].(*types.ContentBlockMemberText)
1074+
require.True(t, ok)
1075+
assert.Equal(t, "Here's my answer", textBlock.Value)
1076+
}

pkg/model/provider/bedrock/convert.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,25 @@ func convertImageURL(imageURL *chat.MessageImageURL) types.ContentBlock {
172172
func convertAssistantContent(msg *chat.Message) []types.ContentBlock {
173173
var blocks []types.ContentBlock
174174

175+
// Add thinking blocks first (order: thinking, text, tool_use)
176+
if msg.ReasoningContent != "" && msg.ThinkingSignature != "" {
177+
blocks = append(blocks, &types.ContentBlockMemberReasoningContent{
178+
Value: &types.ReasoningContentBlockMemberReasoningText{
179+
Value: types.ReasoningTextBlock{
180+
Text: aws.String(msg.ReasoningContent),
181+
Signature: aws.String(msg.ThinkingSignature),
182+
},
183+
},
184+
})
185+
} else if msg.ThinkingSignature != "" {
186+
// Redacted thinking block (signature only)
187+
blocks = append(blocks, &types.ContentBlockMemberReasoningContent{
188+
Value: &types.ReasoningContentBlockMemberRedactedContent{
189+
Value: []byte(msg.ThinkingSignature),
190+
},
191+
})
192+
}
193+
175194
// Add text content if present
176195
if strings.TrimSpace(msg.Content) != "" {
177196
blocks = append(blocks, &types.ContentBlockMemberText{

0 commit comments

Comments
 (0)