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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-13 01:47:47 +07:00
parent 3c3a32e1ff
commit 8314dfedb0
2 changed files with 175 additions and 14 deletions
@@ -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{
@@ -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)
}