Skip to content

Commit 8a1457c

Browse files
feat: add support for anthropic thinking effort (#147)
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
1 parent 26a572c commit 8a1457c

3 files changed

Lines changed: 254 additions & 9 deletions

File tree

providers/anthropic/anthropic.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -263,17 +263,19 @@ func (a languageModel) prepareParams(call fantasy.Call) (*anthropic.MessageNewPa
263263
params.TopP = param.NewOpt(*call.TopP)
264264
}
265265

266-
isThinking := false
267-
var thinkingBudget int64
268-
if providerOptions.Thinking != nil {
269-
isThinking = true
270-
thinkingBudget = providerOptions.Thinking.BudgetTokens
271-
}
272-
if isThinking {
273-
if thinkingBudget == 0 {
266+
switch {
267+
case providerOptions.Effort != nil:
268+
effort := *providerOptions.Effort
269+
params.OutputConfig = anthropic.OutputConfigParam{
270+
Effort: anthropic.OutputConfigEffort(effort),
271+
}
272+
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
273+
params.Thinking.OfAdaptive = &adaptive
274+
case providerOptions.Thinking != nil:
275+
if providerOptions.Thinking.BudgetTokens == 0 {
274276
return nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"}
275277
}
276-
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(thinkingBudget)
278+
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(providerOptions.Thinking.BudgetTokens)
277279
if call.Temperature != nil {
278280
params.Temperature = param.Opt[float64]{}
279281
warnings = append(warnings, fantasy.CallWarning{

providers/anthropic/anthropic_test.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package anthropic
22

33
import (
4+
"context"
5+
"encoding/json"
46
"errors"
7+
"fmt"
8+
"net/http"
9+
"net/http/httptest"
510
"testing"
11+
"time"
612

713
"charm.land/fantasy"
814
"github.com/stretchr/testify/require"
@@ -401,3 +407,223 @@ func TestParseContextTooLargeError(t *testing.T) {
401407
})
402408
}
403409
}
410+
411+
func TestParseOptions_Effort(t *testing.T) {
412+
t.Parallel()
413+
414+
options, err := ParseOptions(map[string]any{
415+
"send_reasoning": true,
416+
"thinking": map[string]any{"budget_tokens": int64(2048)},
417+
"effort": "medium",
418+
"disable_parallel_tool_use": true,
419+
})
420+
require.NoError(t, err)
421+
require.NotNil(t, options.SendReasoning)
422+
require.True(t, *options.SendReasoning)
423+
require.NotNil(t, options.Thinking)
424+
require.Equal(t, int64(2048), options.Thinking.BudgetTokens)
425+
require.NotNil(t, options.Effort)
426+
require.Equal(t, EffortMedium, *options.Effort)
427+
require.NotNil(t, options.DisableParallelToolUse)
428+
require.True(t, *options.DisableParallelToolUse)
429+
}
430+
431+
func TestGenerate_SendsOutputConfigEffort(t *testing.T) {
432+
t.Parallel()
433+
434+
server, calls := newAnthropicJSONServer(mockAnthropicGenerateResponse())
435+
defer server.Close()
436+
437+
provider, err := New(
438+
WithAPIKey("test-api-key"),
439+
WithBaseURL(server.URL),
440+
)
441+
require.NoError(t, err)
442+
443+
model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
444+
require.NoError(t, err)
445+
446+
effort := EffortMedium
447+
_, err = model.Generate(context.Background(), fantasy.Call{
448+
Prompt: testPrompt(),
449+
ProviderOptions: NewProviderOptions(&ProviderOptions{
450+
Effort: &effort,
451+
}),
452+
})
453+
require.NoError(t, err)
454+
455+
call := awaitAnthropicCall(t, calls)
456+
require.Equal(t, "POST", call.method)
457+
require.Equal(t, "/v1/messages", call.path)
458+
requireAnthropicEffort(t, call.body, EffortMedium)
459+
}
460+
461+
func TestStream_SendsOutputConfigEffort(t *testing.T) {
462+
t.Parallel()
463+
464+
server, calls := newAnthropicStreamingServer([]string{
465+
"event: message_start\n",
466+
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
467+
"event: message_stop\n",
468+
"data: {\"type\":\"message_stop\"}\n\n",
469+
})
470+
defer server.Close()
471+
472+
provider, err := New(
473+
WithAPIKey("test-api-key"),
474+
WithBaseURL(server.URL),
475+
)
476+
require.NoError(t, err)
477+
478+
model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
479+
require.NoError(t, err)
480+
481+
effort := EffortHigh
482+
stream, err := model.Stream(context.Background(), fantasy.Call{
483+
Prompt: testPrompt(),
484+
ProviderOptions: NewProviderOptions(&ProviderOptions{
485+
Effort: &effort,
486+
}),
487+
})
488+
require.NoError(t, err)
489+
490+
stream(func(fantasy.StreamPart) bool { return true })
491+
492+
call := awaitAnthropicCall(t, calls)
493+
require.Equal(t, "POST", call.method)
494+
require.Equal(t, "/v1/messages", call.path)
495+
requireAnthropicEffort(t, call.body, EffortHigh)
496+
}
497+
498+
type anthropicCall struct {
499+
method string
500+
path string
501+
body map[string]any
502+
}
503+
504+
func newAnthropicJSONServer(response map[string]any) (*httptest.Server, <-chan anthropicCall) {
505+
calls := make(chan anthropicCall, 4)
506+
507+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
508+
var body map[string]any
509+
if r.Body != nil {
510+
_ = json.NewDecoder(r.Body).Decode(&body)
511+
}
512+
513+
calls <- anthropicCall{
514+
method: r.Method,
515+
path: r.URL.Path,
516+
body: body,
517+
}
518+
519+
w.Header().Set("Content-Type", "application/json")
520+
_ = json.NewEncoder(w).Encode(response)
521+
}))
522+
523+
return server, calls
524+
}
525+
526+
func newAnthropicStreamingServer(chunks []string) (*httptest.Server, <-chan anthropicCall) {
527+
calls := make(chan anthropicCall, 4)
528+
529+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
530+
var body map[string]any
531+
if r.Body != nil {
532+
_ = json.NewDecoder(r.Body).Decode(&body)
533+
}
534+
535+
calls <- anthropicCall{
536+
method: r.Method,
537+
path: r.URL.Path,
538+
body: body,
539+
}
540+
541+
w.Header().Set("Content-Type", "text/event-stream")
542+
w.Header().Set("Cache-Control", "no-cache")
543+
w.Header().Set("Connection", "keep-alive")
544+
w.WriteHeader(http.StatusOK)
545+
546+
for _, chunk := range chunks {
547+
_, _ = fmt.Fprint(w, chunk)
548+
if flusher, ok := w.(http.Flusher); ok {
549+
flusher.Flush()
550+
}
551+
}
552+
}))
553+
554+
return server, calls
555+
}
556+
557+
func awaitAnthropicCall(t *testing.T, calls <-chan anthropicCall) anthropicCall {
558+
t.Helper()
559+
560+
select {
561+
case call := <-calls:
562+
return call
563+
case <-time.After(2 * time.Second):
564+
t.Fatal("timed out waiting for Anthropic request")
565+
return anthropicCall{}
566+
}
567+
}
568+
569+
func assertNoAnthropicCall(t *testing.T, calls <-chan anthropicCall) {
570+
t.Helper()
571+
572+
select {
573+
case call := <-calls:
574+
t.Fatalf("expected no Anthropic API call, but got %s %s", call.method, call.path)
575+
case <-time.After(200 * time.Millisecond):
576+
}
577+
}
578+
579+
func requireAnthropicEffort(t *testing.T, body map[string]any, expected Effort) {
580+
t.Helper()
581+
582+
outputConfig, ok := body["output_config"].(map[string]any)
583+
thinking, ok := body["thinking"].(map[string]any)
584+
require.True(t, ok)
585+
require.Equal(t, string(expected), outputConfig["effort"])
586+
require.Equal(t, "adaptive", thinking["type"])
587+
}
588+
589+
func testPrompt() fantasy.Prompt {
590+
return fantasy.Prompt{
591+
{
592+
Role: fantasy.MessageRoleUser,
593+
Content: []fantasy.MessagePart{
594+
fantasy.TextPart{Text: "Hello"},
595+
},
596+
},
597+
}
598+
}
599+
600+
func mockAnthropicGenerateResponse() map[string]any {
601+
return map[string]any{
602+
"id": "msg_01Test",
603+
"type": "message",
604+
"role": "assistant",
605+
"model": "claude-sonnet-4-20250514",
606+
"content": []any{
607+
map[string]any{
608+
"type": "text",
609+
"text": "Hi there",
610+
},
611+
},
612+
"stop_reason": "end_turn",
613+
"stop_sequence": "",
614+
"usage": map[string]any{
615+
"cache_creation": map[string]any{
616+
"ephemeral_1h_input_tokens": 0,
617+
"ephemeral_5m_input_tokens": 0,
618+
},
619+
"cache_creation_input_tokens": 0,
620+
"cache_read_input_tokens": 0,
621+
"input_tokens": 5,
622+
"output_tokens": 2,
623+
"server_tool_use": map[string]any{
624+
"web_search_requests": 0,
625+
},
626+
"service_tier": "standard",
627+
},
628+
}
629+
}

providers/anthropic/provider_options.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ import (
77
"charm.land/fantasy"
88
)
99

10+
// Effort represents the output effort level for Anthropic models.
11+
//
12+
// This maps to Messages API `output_config.effort`.
13+
type Effort string
14+
15+
const (
16+
// EffortLow represents low output effort.
17+
EffortLow Effort = "low"
18+
// EffortMedium represents medium output effort.
19+
EffortMedium Effort = "medium"
20+
// EffortHigh represents high output effort.
21+
EffortHigh Effort = "high"
22+
// EffortMax represents maximum output effort.
23+
EffortMax Effort = "max"
24+
)
25+
1026
// Global type identifiers for Anthropic-specific provider data.
1127
const (
1228
TypeProviderOptions = Name + ".options"
@@ -43,6 +59,7 @@ func init() {
4359
type ProviderOptions struct {
4460
SendReasoning *bool `json:"send_reasoning"`
4561
Thinking *ThinkingProviderOption `json:"thinking"`
62+
Effort *Effort `json:"effort"`
4663
DisableParallelToolUse *bool `json:"disable_parallel_tool_use"`
4764
}
4865

0 commit comments

Comments
 (0)