Skip to content

Commit 91d8dfd

Browse files
sirozhaclaude
andcommitted
feat: native adaptive thinking for anthropic and bedrock
Add first-class adaptive thinking (Claude Opus 4.7+) alongside the existing budget thinking. ReasoningConfig gains an Adaptive flag and the ReasoningEffort enum gains xhigh/max; WithAdaptiveReasoning is the constructor. When adaptive, the anthropic Messages request and the bedrock Converse additionalModelRequestFields emit thinking.type=adaptive + a sibling output_config.effort (no token budget) and omit temperature/top_p, which adaptive models reject. An empty effort defaults to high. Budget thinking is unchanged. Network-free tests cover the adaptive wire shape on both providers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2a7e660 commit 91d8dfd

7 files changed

Lines changed: 198 additions & 28 deletions

File tree

llms/anthropic/anthropicllm.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,23 @@ func generateMessagesContent(ctx context.Context, o *LLM, messages []llms.Messag
162162
}
163163

164164
var thinking *anthropicclient.ThinkingPayload
165+
var outputConfig *anthropicclient.OutputConfig
165166
if opts.Reasoning.IsEnabled() {
166-
thinking = &anthropicclient.ThinkingPayload{
167-
Type: "enabled",
168-
Budget: opts.Reasoning.GetTokens(opts.GetMaxTokens()),
167+
if opts.Reasoning.Adaptive {
168+
effort := opts.Reasoning.Effort
169+
if effort == llms.ReasoningNone {
170+
effort = llms.ReasoningHigh // the API rejects an empty effort
171+
}
172+
thinking = &anthropicclient.ThinkingPayload{
173+
Type: "adaptive",
174+
Display: "summarized",
175+
}
176+
outputConfig = &anthropicclient.OutputConfig{Effort: string(effort)}
177+
} else {
178+
thinking = &anthropicclient.ThinkingPayload{
179+
Type: "enabled",
180+
Budget: opts.Reasoning.GetTokens(opts.GetMaxTokens()),
181+
}
169182
}
170183
}
171184

@@ -197,9 +210,14 @@ func generateMessagesContent(ctx context.Context, o *LLM, messages []llms.Messag
197210
}
198211
}
199212

200-
// Set temperature to 1.0 for thinking models
201-
temperature, maxTokens := opts.Temperature, opts.GetMaxTokens()
202-
if thinking != nil && thinking.Type == "enabled" && thinking.Budget > 0 {
213+
// Budget thinking pins temperature to 1.0; adaptive models reject sampling
214+
// params, so omit temperature/top_p entirely for them.
215+
temperature, topP, maxTokens := opts.Temperature, opts.TopP, opts.GetMaxTokens()
216+
switch {
217+
case thinking != nil && thinking.Type == "adaptive":
218+
temperature = nil
219+
topP = nil
220+
case thinking != nil && thinking.Type == "enabled" && thinking.Budget > 0:
203221
temperature = getFloatPointer(1.0)
204222
maxTokens = max(thinking.Budget*2, maxTokens) // 2x the budget for thinking
205223
}
@@ -211,10 +229,11 @@ func generateMessagesContent(ctx context.Context, o *LLM, messages []llms.Messag
211229
MaxTokens: &maxTokens,
212230
StopWords: opts.StopWords,
213231
Temperature: temperature,
214-
TopP: opts.TopP,
232+
TopP: topP,
215233
Tools: tools,
216234
ToolChoice: opts.ToolChoice,
217235
Thinking: thinking,
236+
OutputConfig: outputConfig,
218237
BetaHeaders: betaHeaders,
219238
StreamingFunc: opts.StreamingFunc,
220239
})

llms/anthropic/internal/anthropicclient/anthropicclient.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,18 @@ func (c *Client) CreateCompletion(ctx context.Context, r *CompletionRequest) (*C
134134
}
135135

136136
type MessageRequest struct {
137-
Model string `json:"model"`
138-
Messages []ChatMessage `json:"messages"`
139-
System any `json:"system,omitempty"` // Can be string or []Content for caching
140-
Temperature *float64 `json:"temperature,omitempty"`
141-
MaxTokens *int `json:"max_tokens,omitempty"`
142-
TopP *float64 `json:"top_p,omitempty"`
143-
Tools []Tool `json:"tools,omitempty"`
144-
ToolChoice any `json:"tool_choice,omitempty"`
145-
StopWords []string `json:"stop_sequences,omitempty"`
146-
Stream bool `json:"stream,omitempty"`
147-
Thinking *ThinkingPayload `json:"thinking,omitempty"`
137+
Model string `json:"model"`
138+
Messages []ChatMessage `json:"messages"`
139+
System any `json:"system,omitempty"` // Can be string or []Content for caching
140+
Temperature *float64 `json:"temperature,omitempty"`
141+
MaxTokens *int `json:"max_tokens,omitempty"`
142+
TopP *float64 `json:"top_p,omitempty"`
143+
Tools []Tool `json:"tools,omitempty"`
144+
ToolChoice any `json:"tool_choice,omitempty"`
145+
StopWords []string `json:"stop_sequences,omitempty"`
146+
Stream bool `json:"stream,omitempty"`
147+
Thinking *ThinkingPayload `json:"thinking,omitempty"`
148+
OutputConfig *OutputConfig `json:"output_config,omitempty"`
148149

149150
// BetaHeaders are additional beta feature headers to include
150151
BetaHeaders []string `json:"-"`
@@ -166,6 +167,7 @@ func (c *Client) CreateMessage(ctx context.Context, r *MessageRequest) (*Message
166167
Stream: r.Stream,
167168
StreamingFunc: r.StreamingFunc,
168169
Thinking: r.Thinking,
170+
OutputConfig: r.OutputConfig,
169171
}, r.BetaHeaders)
170172
if err != nil {
171173
return nil, err

llms/anthropic/internal/anthropicclient/messages.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ type ChatMessage struct {
6666
type ThinkingPayload struct {
6767
Type string `json:"type"`
6868
Budget int `json:"budget_tokens,omitempty"`
69+
// Display controls reasoning visibility for adaptive thinking; "summarized"
70+
// keeps reasoning text (Opus 4.7+ otherwise default it to "omitted").
71+
Display string `json:"display,omitempty"`
72+
}
73+
74+
// OutputConfig carries the adaptive-thinking effort. It is a top-level sibling of
75+
// "thinking" in the Messages request body.
76+
type OutputConfig struct {
77+
Effort string `json:"effort,omitempty"`
6978
}
7079

7180
type messagePayload struct {
@@ -80,7 +89,8 @@ type messagePayload struct {
8089
Tools []Tool `json:"tools,omitempty"`
8190
ToolChoice any `json:"tool_choice,omitempty"`
8291

83-
Thinking *ThinkingPayload `json:"thinking,omitempty"`
92+
Thinking *ThinkingPayload `json:"thinking,omitempty"`
93+
OutputConfig *OutputConfig `json:"output_config,omitempty"`
8494

8595
StreamingFunc streaming.Callback `json:"-"`
8696
}

llms/anthropic/internal/anthropicclient/messages_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,45 @@ package anthropicclient
22

33
import (
44
"context"
5+
"encoding/json"
56
"net/http"
67
"net/http/httptest"
78
"testing"
89

910
"github.com/stretchr/testify/require"
1011
)
1112

13+
func TestMessagePayload_AdaptiveThinkingSerialization(t *testing.T) {
14+
t.Parallel()
15+
16+
// Temperature/TopP left nil — adaptive models reject sampling params.
17+
payload := &messagePayload{
18+
Model: "claude-opus-4-6",
19+
Thinking: &ThinkingPayload{Type: "adaptive", Display: "summarized"},
20+
OutputConfig: &OutputConfig{Effort: "high"},
21+
}
22+
23+
raw, err := json.Marshal(payload)
24+
require.NoError(t, err)
25+
26+
var got map[string]any
27+
require.NoError(t, json.Unmarshal(raw, &got))
28+
29+
thinking, _ := got["thinking"].(map[string]any)
30+
require.Equal(t, "adaptive", thinking["type"])
31+
require.Equal(t, "summarized", thinking["display"])
32+
_, hasBudget := thinking["budget_tokens"]
33+
require.False(t, hasBudget, "adaptive thinking must not carry a token budget")
34+
35+
outputConfig, _ := got["output_config"].(map[string]any)
36+
require.Equal(t, "high", outputConfig["effort"])
37+
38+
_, hasTemp := got["temperature"]
39+
require.False(t, hasTemp, "adaptive must omit temperature")
40+
_, hasTopP := got["top_p"]
41+
require.False(t, hasTopP, "adaptive must omit top_p")
42+
}
43+
1244
func Test_parseStreamingMessageResponse_withEmptyInput(t *testing.T) {
1345
t.Parallel()
1446
ctx := context.Background()

llms/bedrock/internal/bedrockclient/bedrockclient_converse.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,20 @@ type ConverseInput struct {
6666
type converseThinkingPayload struct {
6767
Type string `json:"type" document:"type"`
6868
BudgetTokens int `json:"budget_tokens,omitempty" document:"budget_tokens,omitempty"`
69+
// Display keeps adaptive reasoning text visible ("summarized"); Opus 4.7+
70+
// otherwise default it to "omitted".
71+
Display string `json:"display,omitempty" document:"display,omitempty"`
72+
}
73+
74+
// converseOutputConfig carries the adaptive-thinking effort. It is a sibling of
75+
// "thinking" inside additionalModelRequestFields.
76+
type converseOutputConfig struct {
77+
Effort string `json:"effort,omitempty" document:"effort,omitempty"`
6978
}
7079

7180
type converseAdditionalModelRequestFields struct {
72-
Thinking *converseThinkingPayload `json:"thinking,omitempty" document:"thinking,omitempty"`
81+
Thinking *converseThinkingPayload `json:"thinking,omitempty" document:"thinking,omitempty"`
82+
OutputConfig *converseOutputConfig `json:"output_config,omitempty" document:"output_config,omitempty"`
7383
}
7484

7585
// buildConverseInput converts our input to AWS Converse format
@@ -132,15 +142,30 @@ func (c *ConverseClient) buildConverseInput(input *ConverseInput) (*bedrockrunti
132142
// Add additional model fields
133143
if input.ReasoningConfig != nil && c.supportsReasoning(input.ModelID) {
134144
additionalModelFields := converseAdditionalModelRequestFields{}
135-
maxTokens := 0 // Use 0 to let it use default maxTokens
136-
if input.MaxTokens != nil {
137-
maxTokens = *input.MaxTokens
138-
}
139-
tokens := input.ReasoningConfig.GetTokens(maxTokens)
140-
if tokens > 0 {
145+
if input.ReasoningConfig.Adaptive {
146+
effort := input.ReasoningConfig.Effort
147+
if effort == llms.ReasoningNone {
148+
effort = llms.ReasoningHigh // the API rejects an empty effort
149+
}
141150
additionalModelFields.Thinking = &converseThinkingPayload{
142-
Type: "enabled",
143-
BudgetTokens: tokens,
151+
Type: "adaptive",
152+
Display: "summarized",
153+
}
154+
additionalModelFields.OutputConfig = &converseOutputConfig{Effort: string(effort)}
155+
// Adaptive models reject sampling params.
156+
inferenceConfig.Temperature = nil
157+
inferenceConfig.TopP = nil
158+
} else {
159+
maxTokens := 0 // Use 0 to let it use default maxTokens
160+
if input.MaxTokens != nil {
161+
maxTokens = *input.MaxTokens
162+
}
163+
tokens := input.ReasoningConfig.GetTokens(maxTokens)
164+
if tokens > 0 {
165+
additionalModelFields.Thinking = &converseThinkingPayload{
166+
Type: "enabled",
167+
BudgetTokens: tokens,
168+
}
144169
}
145170
}
146171
if additionalModelFields.Thinking != nil {

llms/bedrock/internal/bedrockclient/bedrockclient_converse_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package bedrockclient
22

33
import (
44
"context"
5+
"encoding/json"
56
"testing"
67

78
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
@@ -271,6 +272,60 @@ func TestConverseClient_InferenceConfiguration(t *testing.T) {
271272
mockClient.AssertExpectations(t)
272273
}
273274

275+
func TestConverseClient_AdaptiveReasoning(t *testing.T) {
276+
mockClient := &MockBedrockRuntimeClient{}
277+
client := NewConverseClient(mockClient)
278+
279+
var capturedInput *bedrockruntime.ConverseInput
280+
mockClient.On("Converse", mock.Anything, mock.MatchedBy(func(input *bedrockruntime.ConverseInput) bool {
281+
capturedInput = input
282+
return true
283+
}), mock.Anything).Return(&bedrockruntime.ConverseOutput{
284+
Output: &types.ConverseOutputMemberMessage{
285+
Value: types.Message{
286+
Role: types.ConversationRoleAssistant,
287+
Content: []types.ContentBlock{&types.ContentBlockMemberText{Value: "ok"}},
288+
},
289+
},
290+
}, nil)
291+
292+
input := &ConverseInput{
293+
ModelID: "anthropic.claude-opus-4-6-v1:0",
294+
Messages: []Message{{Role: llms.ChatMessageTypeHuman, Content: "Hello", Type: "text"}},
295+
MaxTokens: ptr(2000),
296+
Temperature: ptr(0.8),
297+
TopP: ptr(0.9),
298+
ReasoningConfig: &llms.ReasoningConfig{Effort: llms.ReasoningHigh, Adaptive: true},
299+
}
300+
301+
_, err := client.CreateCompletionConverse(t.Context(), input)
302+
assert.NoError(t, err)
303+
304+
// Adaptive models reject sampling params — temperature/top_p must be omitted.
305+
assert.Nil(t, capturedInput.InferenceConfig.Temperature)
306+
assert.Nil(t, capturedInput.InferenceConfig.TopP)
307+
assert.Equal(t, int32(2000), *capturedInput.InferenceConfig.MaxTokens)
308+
309+
if !assert.NotNil(t, capturedInput.AdditionalModelRequestFields) {
310+
return
311+
}
312+
raw, err := capturedInput.AdditionalModelRequestFields.MarshalSmithyDocument()
313+
assert.NoError(t, err)
314+
var fields map[string]any
315+
assert.NoError(t, json.Unmarshal(raw, &fields))
316+
317+
thinking, _ := fields["thinking"].(map[string]any)
318+
assert.Equal(t, "adaptive", thinking["type"])
319+
assert.Equal(t, "summarized", thinking["display"])
320+
_, hasBudget := thinking["budget_tokens"]
321+
assert.False(t, hasBudget, "adaptive thinking must not carry a token budget")
322+
323+
outputConfig, _ := fields["output_config"].(map[string]any)
324+
assert.Equal(t, "high", outputConfig["effort"])
325+
326+
mockClient.AssertExpectations(t)
327+
}
328+
274329
func TestConverseClient_EmptyResponse(t *testing.T) {
275330
mockClient := &MockBedrockRuntimeClient{}
276331
client := NewConverseClient(mockClient)

llms/options.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,22 @@ const (
3434
ReasoningMedium ReasoningEffort = "medium"
3535
ReasoningLow ReasoningEffort = "low"
3636
ReasoningNone ReasoningEffort = ""
37+
// ReasoningXHigh and ReasoningMax are adaptive-thinking efforts (Claude Opus 4.7+).
38+
// They are valid only with adaptive reasoning, not the budget-token path
39+
// (GetTokens treats them as invalid).
40+
ReasoningXHigh ReasoningEffort = "xhigh"
41+
ReasoningMax ReasoningEffort = "max"
3742
)
3843

3944
// ReasoningConfig is a set of options for reasoning.
4045
type ReasoningConfig struct {
4146
Effort ReasoningEffort `json:"effort"`
4247
Tokens int `json:"tokens"`
48+
// Adaptive selects adaptive thinking (Claude Opus 4.7+): the request carries
49+
// thinking.type=adaptive plus output_config.effort instead of a token budget,
50+
// and the provider omits sampling params (temperature/top_p) that adaptive
51+
// models reject. Effort sets the level; Tokens is ignored.
52+
Adaptive bool `json:"adaptive,omitempty"`
4353
}
4454

4555
// IsEnabled returns true if reasoning is enabled based on the effort and tokens.
@@ -48,6 +58,10 @@ func (r *ReasoningConfig) IsEnabled() bool {
4858
return false
4959
}
5060

61+
if r.Adaptive {
62+
return true
63+
}
64+
5165
if r.Effort == ReasoningNone && r.Tokens == 0 {
5266
return false
5367
}
@@ -616,6 +630,19 @@ func WithReasoning(effort ReasoningEffort, tokens int) CallOption {
616630
}
617631
}
618632

633+
// WithAdaptiveReasoning enables adaptive thinking (Claude Opus 4.7+): the model
634+
// decides how much to think and effort sets the level (low/medium/high/xhigh/max).
635+
// Unlike WithReasoning there is no token budget, and the provider omits sampling
636+
// params (temperature/top_p) that adaptive models reject.
637+
func WithAdaptiveReasoning(effort ReasoningEffort) CallOption {
638+
return func(o *CallOptions) {
639+
o.Reasoning = &ReasoningConfig{
640+
Effort: effort,
641+
Adaptive: true,
642+
}
643+
}
644+
}
645+
619646
// WithFunctionCallBehavior will add an option to set the behavior to use when calling functions.
620647
// Deprecated: Use WithToolChoice instead.
621648
func WithFunctionCallBehavior(behavior FunctionCallBehavior) CallOption {

0 commit comments

Comments
 (0)