@@ -1,8 +1,14 @@
package anthropic
import (
+ "context"
+ "encoding/json"
"errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
"testing"
+ "time"
"charm.land/fantasy"
"github.com/stretchr/testify/require"
@@ -401,3 +407,223 @@ func TestParseContextTooLargeError(t *testing.T) {
})
}
}
+
+func TestParseOptions_Effort(t *testing.T) {
+ t.Parallel()
+
+ options, err := ParseOptions(map[string]any{
+ "send_reasoning": true,
+ "thinking": map[string]any{"budget_tokens": int64(2048)},
+ "effort": "medium",
+ "disable_parallel_tool_use": true,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, options.SendReasoning)
+ require.True(t, *options.SendReasoning)
+ require.NotNil(t, options.Thinking)
+ require.Equal(t, int64(2048), options.Thinking.BudgetTokens)
+ require.NotNil(t, options.Effort)
+ require.Equal(t, EffortMedium, *options.Effort)
+ require.NotNil(t, options.DisableParallelToolUse)
+ require.True(t, *options.DisableParallelToolUse)
+}
+
+func TestGenerate_SendsOutputConfigEffort(t *testing.T) {
+ t.Parallel()
+
+ server, calls := newAnthropicJSONServer(mockAnthropicGenerateResponse())
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ effort := EffortMedium
+ _, err = model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ ProviderOptions: NewProviderOptions(&ProviderOptions{
+ Effort: &effort,
+ }),
+ })
+ require.NoError(t, err)
+
+ call := awaitAnthropicCall(t, calls)
+ require.Equal(t, "POST", call.method)
+ require.Equal(t, "/v1/messages", call.path)
+ requireAnthropicEffort(t, call.body, EffortMedium)
+}
+
+func TestStream_SendsOutputConfigEffort(t *testing.T) {
+ t.Parallel()
+
+ server, calls := newAnthropicStreamingServer([]string{
+ "event: message_start\n",
+ "data: {\"type\":\"message_start\",\"message\":{}}\n\n",
+ "event: message_stop\n",
+ "data: {\"type\":\"message_stop\"}\n\n",
+ })
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ effort := EffortHigh
+ stream, err := model.Stream(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ ProviderOptions: NewProviderOptions(&ProviderOptions{
+ Effort: &effort,
+ }),
+ })
+ require.NoError(t, err)
+
+ stream(func(fantasy.StreamPart) bool { return true })
+
+ call := awaitAnthropicCall(t, calls)
+ require.Equal(t, "POST", call.method)
+ require.Equal(t, "/v1/messages", call.path)
+ requireAnthropicEffort(t, call.body, EffortHigh)
+}
+
+type anthropicCall struct {
+ method string
+ path string
+ body map[string]any
+}
+
+func newAnthropicJSONServer(response map[string]any) (*httptest.Server, <-chan anthropicCall) {
+ calls := make(chan anthropicCall, 4)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ }
+
+ calls <- anthropicCall{
+ method: r.Method,
+ path: r.URL.Path,
+ body: body,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+
+ return server, calls
+}
+
+func newAnthropicStreamingServer(chunks []string) (*httptest.Server, <-chan anthropicCall) {
+ calls := make(chan anthropicCall, 4)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ }
+
+ calls <- anthropicCall{
+ method: r.Method,
+ path: r.URL.Path,
+ body: body,
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.WriteHeader(http.StatusOK)
+
+ for _, chunk := range chunks {
+ _, _ = fmt.Fprint(w, chunk)
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ }
+ }))
+
+ return server, calls
+}
+
+func awaitAnthropicCall(t *testing.T, calls <-chan anthropicCall) anthropicCall {
+ t.Helper()
+
+ select {
+ case call := <-calls:
+ return call
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for Anthropic request")
+ return anthropicCall{}
+ }
+}
+
+func assertNoAnthropicCall(t *testing.T, calls <-chan anthropicCall) {
+ t.Helper()
+
+ select {
+ case call := <-calls:
+ t.Fatalf("expected no Anthropic API call, but got %s %s", call.method, call.path)
+ case <-time.After(200 * time.Millisecond):
+ }
+}
+
+func requireAnthropicEffort(t *testing.T, body map[string]any, expected Effort) {
+ t.Helper()
+
+ outputConfig, ok := body["output_config"].(map[string]any)
+ thinking, ok := body["thinking"].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, string(expected), outputConfig["effort"])
+ require.Equal(t, "adaptive", thinking["type"])
+}
+
+func testPrompt() fantasy.Prompt {
+ return fantasy.Prompt{
+ {
+ Role: fantasy.MessageRoleUser,
+ Content: []fantasy.MessagePart{
+ fantasy.TextPart{Text: "Hello"},
+ },
+ },
+ }
+}
+
+func mockAnthropicGenerateResponse() map[string]any {
+ return map[string]any{
+ "id": "msg_01Test",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-4-20250514",
+ "content": []any{
+ map[string]any{
+ "type": "text",
+ "text": "Hi there",
+ },
+ },
+ "stop_reason": "end_turn",
+ "stop_sequence": "",
+ "usage": map[string]any{
+ "cache_creation": map[string]any{
+ "ephemeral_1h_input_tokens": 0,
+ "ephemeral_5m_input_tokens": 0,
+ },
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 5,
+ "output_tokens": 2,
+ "server_tool_use": map[string]any{
+ "web_search_requests": 0,
+ },
+ "service_tier": "standard",
+ },
+ }
+}