From 8314dfedb031cb72a2c33a222dbbe3e47c6cbe79 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Mon, 13 Jul 2026 01:47:47 +0700 Subject: [PATCH] fix(bedrock): honor adaptive thinking on the legacy InvokeModel path WithAdaptiveReasoning on the default (non-Converse) Bedrock client was silently converted into thinking.type=enabled with a token budget, which Opus 4.7+ generations reject with a 400. The legacy path now mirrors the native Anthropic client: thinking.type=adaptive with summarized display, output_config.effort, and no sampling params. Budget requests keep the existing version-gated behavior byte for byte. Reasoning construction moves into applyAnthropicReasoning so the request shape is unit-testable; covered by JSON-level tests for the adaptive, budget, version-gate, and nil-config branches. Co-Authored-By: Claude Fable 5 --- .../bedrockclient/provider_anthropic.go | 66 ++++++++-- .../provider_anthropic_reasoning_test.go | 123 ++++++++++++++++++ 2 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 llms/bedrock/internal/bedrockclient/provider_anthropic_reasoning_test.go diff --git a/llms/bedrock/internal/bedrockclient/provider_anthropic.go b/llms/bedrock/internal/bedrockclient/provider_anthropic.go index bd61145a..b209c855 100644 --- a/llms/bedrock/internal/bedrockclient/provider_anthropic.go +++ b/llms/bedrock/internal/bedrockclient/provider_anthropic.go @@ -97,6 +97,9 @@ type anthropicTextGenerationInput struct { Tools []anthropicTool `json:"tools,omitempty"` // Thinking configuration for reasoning models. Optional Thinking *anthropicThinkingPayload `json:"thinking,omitempty"` + // OutputConfig carries the adaptive-thinking effort. It is a top-level + // sibling of "thinking" in the Messages request body. Optional + OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"` } type anthropicTool struct { @@ -109,6 +112,13 @@ type anthropicTool struct { type anthropicThinkingPayload struct { Type string `json:"type"` BudgetTokens int `json:"budget_tokens,omitempty"` + // Display keeps adaptive reasoning text visible ("summarized"); Opus 4.7+ + // otherwise omits it by default. + Display string `json:"display,omitempty"` +} + +type anthropicOutputConfig struct { + Effort string `json:"effort,omitempty"` } // anthropicTextGenerationOutput is the generated output. @@ -215,20 +225,7 @@ func createAnthropicCompletion(ctx context.Context, Tools: tools, } - // Add thinking configuration for reasoning models - if options.Reasoning != nil && supportsAnthropicReasoning(modelID) { - maxTokens := options.GetMaxTokens() - if maxTokens == 0 { - maxTokens = 2048 - } - tokens := options.Reasoning.GetTokens(maxTokens) - if tokens > 0 { - input.Thinking = &anthropicThinkingPayload{ - Type: "enabled", - BudgetTokens: tokens, - } - } - } + applyAnthropicReasoning(&input, options.Reasoning, modelID, options.GetMaxTokens()) body, err := json.Marshal(input) if err != nil { @@ -655,6 +652,47 @@ func getAnthropicInputContent(message Message) anthropicTextGenerationInputConte return c } +// applyAnthropicReasoning mirrors the native Anthropic client: adaptive requests +// carry thinking.type=adaptive plus output_config.effort and always drop sampling +// params (Opus 4.7+ generations reject them); budget requests keep the +// version-gated thinking.type=enabled payload. +func applyAnthropicReasoning( + input *anthropicTextGenerationInput, cfg *llms.ReasoningConfig, modelID string, maxTokens int, +) { + if cfg == nil { + return + } + + if cfg.Adaptive { + effort := cfg.Effort + if effort == llms.ReasoningNone { + effort = llms.ReasoningHigh // match the server default instead of sending an empty output_config + } + input.Thinking = &anthropicThinkingPayload{ + Type: "adaptive", + Display: "summarized", + } + input.OutputConfig = &anthropicOutputConfig{Effort: string(effort)} + input.Temperature = 0 + input.TopP = 0 + input.TopK = 0 + return + } + + if !supportsAnthropicReasoning(modelID) { + return + } + if maxTokens == 0 { + maxTokens = 2048 + } + if tokens := cfg.GetTokens(maxTokens); tokens > 0 { + input.Thinking = &anthropicThinkingPayload{ + Type: "enabled", + BudgetTokens: tokens, + } + } +} + // supportsAnthropicReasoning checks if the model supports reasoning func supportsAnthropicReasoning(modelID string) bool { reasoningModels := []string{ diff --git a/llms/bedrock/internal/bedrockclient/provider_anthropic_reasoning_test.go b/llms/bedrock/internal/bedrockclient/provider_anthropic_reasoning_test.go new file mode 100644 index 00000000..786b10a0 --- /dev/null +++ b/llms/bedrock/internal/bedrockclient/provider_anthropic_reasoning_test.go @@ -0,0 +1,123 @@ +package bedrockclient + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vxcontrol/langchaingo/llms" +) + +func marshalAnthropicInput(t *testing.T, input anthropicTextGenerationInput) map[string]any { + t.Helper() + raw, err := json.Marshal(input) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(raw, &fields)) + return fields +} + +func TestApplyAnthropicReasoning_Adaptive(t *testing.T) { + t.Parallel() + + input := anthropicTextGenerationInput{ + MaxTokens: 2048, + Temperature: 0.8, + TopP: 0.9, + TopK: 40, + } + applyAnthropicReasoning(&input, + &llms.ReasoningConfig{Effort: llms.ReasoningXHigh, Adaptive: true}, + "anthropic.claude-opus-4-7-v1:0", 2048) + + fields := marshalAnthropicInput(t, input) + + thinking, _ := fields["thinking"].(map[string]any) + assert.Equal(t, "adaptive", thinking["type"]) + assert.Equal(t, "summarized", thinking["display"]) + _, hasBudget := thinking["budget_tokens"] + assert.False(t, hasBudget, "adaptive thinking must not carry a token budget") + + outputConfig, _ := fields["output_config"].(map[string]any) + assert.Equal(t, "xhigh", outputConfig["effort"]) + + for _, key := range []string{"temperature", "top_p", "top_k"} { + _, has := fields[key] + assert.False(t, has, "adaptive must omit %s", key) + } +} + +func TestApplyAnthropicReasoning_AdaptiveBypassesVersionGate(t *testing.T) { + t.Parallel() + + // A model family newer than the budget allowlist knows must still carry adaptive. + input := anthropicTextGenerationInput{MaxTokens: 2048} + applyAnthropicReasoning(&input, + &llms.ReasoningConfig{Effort: llms.ReasoningHigh, Adaptive: true}, + "us.anthropic.claude-sonnet-5-v1:0", 2048) + + require.NotNil(t, input.Thinking) + assert.Equal(t, "adaptive", input.Thinking.Type) + require.NotNil(t, input.OutputConfig) + assert.Equal(t, "high", input.OutputConfig.Effort) +} + +func TestApplyAnthropicReasoning_AdaptiveEmptyEffortDefaultsToHigh(t *testing.T) { + t.Parallel() + + input := anthropicTextGenerationInput{MaxTokens: 2048} + applyAnthropicReasoning(&input, + &llms.ReasoningConfig{Adaptive: true}, + "anthropic.claude-opus-4-7-v1:0", 2048) + + require.NotNil(t, input.OutputConfig) + assert.Equal(t, "high", input.OutputConfig.Effort) +} + +func TestApplyAnthropicReasoning_Budget(t *testing.T) { + t.Parallel() + + input := anthropicTextGenerationInput{ + MaxTokens: 8000, + Temperature: 0.8, + } + applyAnthropicReasoning(&input, + &llms.ReasoningConfig{Effort: llms.ReasoningMedium}, + "anthropic.claude-opus-4-6-v1:0", 8000) + + fields := marshalAnthropicInput(t, input) + + thinking, _ := fields["thinking"].(map[string]any) + assert.Equal(t, "enabled", thinking["type"]) + assert.EqualValues(t, 2666, thinking["budget_tokens"]) // medium => max(8000/3, 2048) + _, hasDisplay := thinking["display"] + assert.False(t, hasDisplay, "budget thinking has no display field") + + _, hasOutputConfig := fields["output_config"] + assert.False(t, hasOutputConfig, "budget thinking has no output_config") + + assert.EqualValues(t, 0.8, fields["temperature"], "the refactor must not alter budget sampling params") +} + +func TestApplyAnthropicReasoning_BudgetKeepsVersionGate(t *testing.T) { + t.Parallel() + + input := anthropicTextGenerationInput{MaxTokens: 2048} + applyAnthropicReasoning(&input, + &llms.ReasoningConfig{Effort: llms.ReasoningMedium}, + "anthropic.claude-v2", 2048) + + assert.Nil(t, input.Thinking, "budget thinking stays gated to the reasoning allowlist") +} + +func TestApplyAnthropicReasoning_NilConfig(t *testing.T) { + t.Parallel() + + input := anthropicTextGenerationInput{MaxTokens: 2048, Temperature: 0.8} + applyAnthropicReasoning(&input, nil, "anthropic.claude-opus-4-7-v1:0", 2048) + + assert.Nil(t, input.Thinking) + assert.Nil(t, input.OutputConfig) + assert.EqualValues(t, 0.8, input.Temperature) +}