mirror of
https://github.com/vxcontrol/langchaingo.git
synced 2026-07-19 21:54:17 -04:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user