diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a89a2675c..5c15e5118 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,12 +12,11 @@ jobs: GOTOOLCHAIN: local steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # The reusable charmbracelet/meta govulncheck job always reads go.mod. - # Keep the module baseline at Go 1.25.0, but run the scanner with Go 1.26.4 - # so it uses a fixed standard library. + # Keep the module baseline in go.mod, but run the scanner with Go 1.26.5 + # so it uses a fixed standard library (GO-2026-5856). - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.4" + go-version: "1.26.5" cache: true check-latest: true - run: | diff --git a/go.mod b/go.mod index 2927fed05..da9769cd5 100644 --- a/go.mod +++ b/go.mod @@ -110,7 +110,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.53.0 // indirect - golang.org/x/image v0.42.0 // indirect + golang.org/x/image v0.43.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect diff --git a/go.sum b/go.sum index 20ab82abe..4f617700f 100644 --- a/go.sum +++ b/go.sum @@ -245,8 +245,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= -golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 4f1097cca..21426a2e6 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -55,7 +55,178 @@ func defaultsToAdaptiveThinking(model string) bool { } func requiresAdaptiveThinking(model string) bool { - return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) + return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) || thinkingOnByDefault(model) +} + +// Claude models before 4.6 reject adaptive thinking. Unknown versions use +// legacy budget thinking unless explicitly allowlisted. +func supportsAdaptiveThinking(model string) bool { + if defaultsToAdaptiveThinking(model) { + return true + } + major, minor, ok := claudeVersion(model) + if !ok { + return false + } + return major > 4 || (major == 4 && minor >= 6) +} + +func claudeVersion(model string) (major, minor int, ok bool) { + model = strings.ToLower(strings.TrimSpace(model)) + _, rest, found := strings.Cut(model, "claude-") + if !found { + return 0, 0, false + } + // Vertex model IDs put the version before an "@" suffix + // (claude-opus-4-7@20260101). + rest, _, _ = strings.Cut(rest, "@") + parts := strings.Split(rest, "-") + for i, part := range parts { + v, valid := shortVersionComponent(part) + if !valid { + continue + } + major = v + if i+1 < len(parts) { + if m, validMinor := shortVersionComponent(parts[i+1]); validMinor { + minor = m + } + } + return major, minor, true + } + return 0, 0, false +} + +func shortVersionComponent(s string) (int, bool) { + if len(s) == 0 || len(s) > 2 { + return 0, false + } + v, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return v, true +} + +// Opus 4.5 accepts output_config.effort alongside manual budget thinking, +// unlike other pre-adaptive models. +func isOpus45(model string) bool { + major, minor, ok := claudeVersion(model) + return ok && major == 4 && minor == 5 && + strings.Contains(strings.ToLower(strings.TrimSpace(model)), "opus") +} + +// Opus 4.5 supports only low, medium, and high effort. +func normalizeOpus45Effort(effort Effort) Effort { + switch effort { + case EffortMinimal: + return EffortLow + case EffortXHigh, EffortMax: + return EffortHigh + default: + return effort + } +} + +// Mythos and Fable models run adaptive thinking unconditionally and reject +// thinking: {type: "disabled"} with an HTTP 400. +func rejectsDisabledThinking(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(model, "claude-mythos") || strings.Contains(model, "claude-fable") +} + +// Claude 5+ models run adaptive thinking when the request omits the thinking +// field, so an explicit opt-out must send thinking: {type: "disabled"}. +func thinkingOnByDefault(model string) bool { + major, _, ok := claudeVersion(model) + return ok && major >= 5 +} + +// Extended thinking with budget_tokens shipped with Claude 3.7; older +// models reject the thinking field entirely. +func supportsBudgetThinking(model string) bool { + major, minor, ok := claudeVersion(model) + return ok && (major > 3 || (major == 3 && minor >= 7)) +} + +// The xhigh effort tier shipped with Claude Opus 4.7; 4.6-era adaptive +// models accept only low, medium, high, and max. +func supportsXHighEffort(model string) bool { + major, minor, ok := claudeVersion(model) + return ok && (major > 4 || (major == 4 && minor >= 7)) +} + +// Anthropic does not accept minimal for output_config.effort, and xhigh +// only on models that support it. +func normalizeEffort(effort Effort, model string) Effort { + switch effort { + case EffortMinimal: + return EffortLow + case EffortXHigh: + if !supportsXHighEffort(model) { + return EffortMax + } + return effort + default: + return effort + } +} + +// Anthropic requires at least 1024 thinking tokens. Smaller derived budgets +// disable thinking to preserve the requested output limit. +func legacyEffortBudget(effort Effort, maxTokens int64) int64 { + const minBudget = 1024 + var budget int64 + switch effort { + case EffortMinimal: + budget = minBudget + case EffortLow: + budget = int64(float64(maxTokens) * 0.2) + case EffortHigh: + budget = int64(float64(maxTokens) * 0.8) + case EffortXHigh: + budget = int64(float64(maxTokens) * 0.9) + case EffortMax: + budget = int64(float64(maxTokens) * 0.95) + default: + budget = int64(float64(maxTokens) * 0.5) + } + // budget_tokens must be strictly less than max_tokens. + if budget >= maxTokens { + budget = maxTokens - 1 + } + if budget < minBudget { + return 0 + } + return budget +} + +func stripThinkingUnsupportedParams(params *anthropic.MessageNewParams, call fantasy.Call, warnings []fantasy.CallWarning) []fantasy.CallWarning { + if call.Temperature != nil { + params.Temperature = param.Opt[float64]{} + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeUnsupportedSetting, + Setting: "temperature", + Details: "temperature is not supported when thinking is enabled", + }) + } + if call.TopP != nil { + params.TopP = param.Opt[float64]{} + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeUnsupportedSetting, + Setting: "TopP", + Details: "TopP is not supported when thinking is enabled", + }) + } + if call.TopK != nil { + params.TopK = param.Opt[int64]{} + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeUnsupportedSetting, + Setting: "TopK", + Details: "TopK is not supported when thinking is enabled", + }) + } + return warnings } func setThinkingDisplay(param interface{ SetExtraFields(map[string]any) }, display ThinkingDisplay) { @@ -392,14 +563,49 @@ func (a languageModel) prepareParams(call fantasy.Call) ( switch { case providerOptions.Effort != nil: effort := *providerOptions.Effort - params.OutputConfig = anthropic.OutputConfigParam{ - Effort: anthropic.OutputConfigEffort(effort), - } - adaptive := anthropic.NewThinkingConfigAdaptiveParam() - if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { - setThinkingDisplay(&adaptive, display) + switch { + case effort == EffortNone: + switch { + case rejectsDisabledThinking(a.modelID): + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "thinking cannot be disabled on " + a.modelID, + }) + case thinkingOnByDefault(a.modelID): + disabled := anthropic.NewThinkingConfigDisabledParam() + params.Thinking.OfDisabled = &disabled + } + case supportsAdaptiveThinking(a.modelID): + params.OutputConfig = anthropic.OutputConfigParam{ + Effort: anthropic.OutputConfigEffort(normalizeEffort(effort, a.modelID)), + } + adaptive := anthropic.NewThinkingConfigAdaptiveParam() + if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { + setThinkingDisplay(&adaptive, display) + } + params.Thinking.OfAdaptive = &adaptive + default: + if isOpus45(a.modelID) { + params.OutputConfig = anthropic.OutputConfigParam{ + Effort: anthropic.OutputConfigEffort(normalizeOpus45Effort(effort)), + } + } + if !supportsBudgetThinking(a.modelID) { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeUnsupportedSetting, + Setting: "effort", + Details: "thinking is not supported on " + a.modelID, + }) + break + } + if budget := legacyEffortBudget(effort, params.MaxTokens); budget > 0 { + params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget) + if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { + setThinkingDisplay(params.Thinking.OfEnabled, display) + } + warnings = stripThinkingUnsupportedParams(params, call, warnings) + } } - params.Thinking.OfAdaptive = &adaptive case providerOptions.Thinking != nil: if providerOptions.Thinking.BudgetTokens == 0 { return nil, nil, nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"} @@ -416,30 +622,7 @@ func (a languageModel) prepareParams(call fantasy.Call) ( setThinkingDisplay(params.Thinking.OfEnabled, display) } } - if call.Temperature != nil { - params.Temperature = param.Opt[float64]{} - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeUnsupportedSetting, - Setting: "temperature", - Details: "temperature is not supported when thinking is enabled", - }) - } - if call.TopP != nil { - params.TopP = param.Opt[float64]{} - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeUnsupportedSetting, - Setting: "TopP", - Details: "TopP is not supported when thinking is enabled", - }) - } - if call.TopK != nil { - params.TopK = param.Opt[int64]{} - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeUnsupportedSetting, - Setting: "TopK", - Details: "TopK is not supported when thinking is enabled", - }) - } + warnings = stripThinkingUnsupportedParams(params, call, warnings) case defaultsToAdaptiveThinking(a.modelID): adaptive := anthropic.NewThinkingConfigAdaptiveParam() if display, ok := thinkingDisplay(providerOptions, a.modelID); ok { diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 9bbf1232a..f45b29870 100644 --- a/providers/anthropic/anthropic_test.go +++ b/providers/anthropic/anthropic_test.go @@ -640,7 +640,7 @@ func TestGenerate_SendsOutputConfigEffort(t *testing.T) { ) require.NoError(t, err) - model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-6") require.NoError(t, err) effort := EffortMedium @@ -658,6 +658,340 @@ func TestGenerate_SendsOutputConfigEffort(t *testing.T) { requireAnthropicEffort(t, call.body, EffortMedium) } +func TestSupportsAdaptiveThinking(t *testing.T) { + t.Parallel() + + tests := []struct { + model string + want bool + }{ + {model: "claude-sonnet-4-6", want: true}, + {model: "claude-sonnet-5", want: true}, + {model: "claude-opus-4-7-20260101", want: true}, + {model: "claude-opus-4-10-20260101", want: true}, + {model: "claude-fable-5", want: true}, + {model: "claude-mythos-5", want: true}, + {model: "claude-mythos-preview", want: true}, + {model: "us.anthropic.claude-opus-4-8-20260101-v1:0", want: true}, + {model: "claude-opus-4-7@20260101", want: true}, + {model: "claude-sonnet-4-6@20250929", want: true}, + {model: "claude-haiku-4-5@20251001", want: false}, + {model: "claude-3-5-sonnet-v2@20241022", want: false}, + {model: "claude-haiku-4-5", want: false}, + {model: "claude-haiku-4-5-20251001", want: false}, + {model: "claude-sonnet-4-5-20250929", want: false}, + {model: "claude-sonnet-4-20250514", want: false}, + {model: "claude-3-5-haiku-20241022", want: false}, + {model: "claude-3-7-sonnet-20250219", want: false}, + {model: "us.anthropic.claude-haiku-4-5-20251001-v1:0", want: false}, + {model: "global.anthropic.claude-3-5-sonnet-20241022-v2:0", want: false}, + {model: "claude-2.1", want: false}, + {model: "not-a-claude-model", want: false}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, supportsAdaptiveThinking(tt.model)) + }) + } +} + +func ptrTo[T any](v T) *T { + return &v +} + +func TestGenerate_LegacyEffortConversion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + options *ProviderOptions + maxOutputTokens *int64 + wantBudget int64 + }{ + { + name: "haiku 4.5 derives budget from max tokens", + model: "claude-haiku-4-5", + options: &ProviderOptions{Effort: ptrTo(EffortHigh)}, + maxOutputTokens: ptrTo(int64(4096)), + wantBudget: 3276, + }, + { + name: "small max tokens omits thinking", + model: "claude-haiku-4-5", + options: &ProviderOptions{Effort: ptrTo(EffortHigh)}, + maxOutputTokens: ptrTo(int64(256)), + wantBudget: 0, + }, + { + name: "minimal effort uses the budget floor", + model: "claude-haiku-4-5", + options: &ProviderOptions{Effort: ptrTo(EffortMinimal)}, + maxOutputTokens: ptrTo(int64(4096)), + wantBudget: 1024, + }, + { + name: "bedrock prefixed haiku 4.5 derives budget", + model: "us.anthropic.claude-haiku-4-5-20251001-v1:0", + options: &ProviderOptions{Effort: ptrTo(EffortHigh)}, + maxOutputTokens: ptrTo(int64(4096)), + wantBudget: 3276, + }, + { + name: "claude 3.7 derives budget", + model: "claude-3-7-sonnet-20250219", + options: &ProviderOptions{Effort: ptrTo(EffortMedium)}, + maxOutputTokens: ptrTo(int64(4096)), + wantBudget: 2048, + }, + { + name: "pre-3.7 model omits thinking", + model: "claude-3-5-haiku-20241022", + options: &ProviderOptions{Effort: ptrTo(EffortMedium)}, + maxOutputTokens: ptrTo(int64(4096)), + wantBudget: 0, + }, + { + name: "bedrock prefixed pre-3.7 model omits thinking", + model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + options: &ProviderOptions{Effort: ptrTo(EffortHigh)}, + maxOutputTokens: ptrTo(int64(4096)), + wantBudget: 0, + }, + { + name: "derived budget wins over configured thinking budget", + model: "claude-haiku-4-5", + options: &ProviderOptions{ + Effort: ptrTo(EffortHigh), + Thinking: &ThinkingProviderOption{BudgetTokens: 2048}, + }, + maxOutputTokens: ptrTo(int64(4096)), + wantBudget: 3276, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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(), tt.model) + require.NoError(t, err) + + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt(), + MaxOutputTokens: tt.maxOutputTokens, + ProviderOptions: NewProviderOptions(tt.options), + }) + require.NoError(t, err) + + call := awaitAnthropicCall(t, calls) + require.NotContains(t, call.body, "output_config") + if tt.wantBudget == 0 { + require.NotContains(t, call.body, "thinking") + return + } + thinking, ok := call.body["thinking"].(map[string]any) + require.True(t, ok) + require.Equal(t, "enabled", thinking["type"]) + require.InDelta(t, tt.wantBudget, thinking["budget_tokens"], 0) + }) + } +} + +func TestGenerate_Opus45KeepsEffortParameter(t *testing.T) { + t.Parallel() + + tests := []struct { + model string + effort Effort + wantEffort Effort + }{ + {model: "claude-opus-4-5-20251101", effort: EffortHigh, wantEffort: EffortHigh}, + {model: "claude-opus-4-5-20251101", effort: EffortMinimal, wantEffort: EffortLow}, + {model: "claude-opus-4-5-20251101", effort: EffortXHigh, wantEffort: EffortHigh}, + {model: "claude-opus-4-5-20251101", effort: EffortMax, wantEffort: EffortHigh}, + {model: "us.anthropic.claude-opus-4-5-20251101-v1:0", effort: EffortHigh, wantEffort: EffortHigh}, + } + + for _, tt := range tests { + t.Run(tt.model+"/"+string(tt.effort), func(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(), tt.model) + require.NoError(t, err) + + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt(), + MaxOutputTokens: ptrTo(int64(4096)), + ProviderOptions: NewProviderOptions(&ProviderOptions{Effort: ptrTo(tt.effort)}), + }) + require.NoError(t, err) + + call := awaitAnthropicCall(t, calls) + outputConfig, ok := call.body["output_config"].(map[string]any) + require.True(t, ok) + require.Equal(t, string(tt.wantEffort), outputConfig["effort"]) + thinking, ok := call.body["thinking"].(map[string]any) + require.True(t, ok) + require.Equal(t, "enabled", thinking["type"]) + }) + } +} + +func TestGenerate_EffortNoneDisablesThinking(t *testing.T) { + t.Parallel() + + tests := []struct { + model string + wantDisabled bool + }{ + {model: "claude-haiku-4-5"}, + {model: "claude-sonnet-4-6"}, + {model: "claude-sonnet-5", wantDisabled: true}, + {model: "claude-mythos-preview"}, + {model: "claude-fable-5"}, + } + + for _, tt := range tests { + t.Run(tt.model, func(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) + + languageModel, err := provider.LanguageModel(context.Background(), tt.model) + require.NoError(t, err) + + _, err = languageModel.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt(), + ProviderOptions: NewProviderOptions(&ProviderOptions{ + Effort: ptrTo(EffortNone), + }), + }) + require.NoError(t, err) + + call := awaitAnthropicCall(t, calls) + if tt.wantDisabled { + require.Equal(t, map[string]any{"type": "disabled"}, call.body["thinking"]) + } else { + require.NotContains(t, call.body, "thinking") + } + require.NotContains(t, call.body, "output_config") + }) + } +} + +func TestGenerate_NormalizesEffortForAdaptiveModels(t *testing.T) { + t.Parallel() + + tests := []struct { + model string + effort Effort + want Effort + }{ + {model: "claude-sonnet-4-6", effort: EffortMinimal, want: EffortLow}, + {model: "claude-sonnet-4-6", effort: EffortXHigh, want: EffortMax}, + {model: "claude-sonnet-4-6", effort: EffortHigh, want: EffortHigh}, + {model: "claude-opus-4-7-20260101", effort: EffortXHigh, want: EffortXHigh}, + {model: "claude-sonnet-5", effort: EffortXHigh, want: EffortXHigh}, + } + + for _, tt := range tests { + t.Run(tt.model+"/"+string(tt.effort), func(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(), tt.model) + require.NoError(t, err) + + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt(), + ProviderOptions: NewProviderOptions(&ProviderOptions{ + Effort: ptrTo(tt.effort), + }), + }) + require.NoError(t, err) + + call := awaitAnthropicCall(t, calls) + requireAnthropicEffort(t, call.body, tt.want) + }) + } +} + +func TestGenerate_LegacyEffortStripsSamplingParams(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-haiku-4-5") + require.NoError(t, err) + + resp, err := model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt(), + Temperature: ptrTo(0.7), + TopP: ptrTo(0.9), + TopK: ptrTo(int64(40)), + ProviderOptions: NewProviderOptions(&ProviderOptions{ + Effort: ptrTo(EffortHigh), + }), + }) + require.NoError(t, err) + + call := awaitAnthropicCall(t, calls) + require.NotContains(t, call.body, "temperature") + require.NotContains(t, call.body, "top_p") + require.NotContains(t, call.body, "top_k") + + var stripped []string + for _, warning := range resp.Warnings { + if warning.Type == fantasy.CallWarningTypeUnsupportedSetting { + stripped = append(stripped, warning.Setting) + } + } + require.ElementsMatch(t, []string{"temperature", "TopP", "TopK"}, stripped) +} + func TestGenerate_SendsThinkingDisplay(t *testing.T) { t.Parallel() @@ -671,7 +1005,7 @@ func TestGenerate_SendsThinkingDisplay(t *testing.T) { }{ { name: "explicit display with adaptive thinking", - model: "claude-sonnet-4-20250514", + model: "claude-sonnet-4-6", options: func() *ProviderOptions { effort := EffortMedium display := ThinkingDisplayOmitted @@ -680,6 +1014,18 @@ func TestGenerate_SendsThinkingDisplay(t *testing.T) { wantType: "adaptive", wantDisplay: "omitted", }, + { + name: "explicit display with legacy effort thinking", + model: "claude-haiku-4-5", + options: func() *ProviderOptions { + effort := EffortMedium + display := ThinkingDisplayOmitted + return &ProviderOptions{Effort: &effort, ThinkingDisplay: &display} + }, + wantType: "enabled", + wantDisplay: "omitted", + wantBudget: 2048, + }, { name: "explicit display with budget thinking", model: "claude-sonnet-4-20250514", @@ -743,6 +1089,14 @@ func TestGenerate_SendsThinkingDisplay(t *testing.T) { wantType: "adaptive", wantDisplay: "summarized", }, + { + name: "claude 5 models use adaptive thinking when budget thinking configured", + model: "claude-sonnet-5", + options: func() *ProviderOptions { + return &ProviderOptions{Thinking: &ThinkingProviderOption{BudgetTokens: 2048}} + }, + wantType: "adaptive", + }, { name: "older opus models keep provider default", model: "claude-opus-4-6-20260101", @@ -863,7 +1217,7 @@ func TestStream_SendsOutputConfigEffort(t *testing.T) { ) require.NoError(t, err) - model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-6") require.NoError(t, err) effort := EffortHigh diff --git a/providers/anthropic/provider_options.go b/providers/anthropic/provider_options.go index eb377f7bd..9a077a2b0 100644 --- a/providers/anthropic/provider_options.go +++ b/providers/anthropic/provider_options.go @@ -8,11 +8,16 @@ import ( ) // Effort represents the output effort level for Anthropic models. -// -// This maps to Messages API `output_config.effort`. +// EffortMinimal is normalized to EffortLow before sending, EffortXHigh +// falls back to EffortMax on models without xhigh support, and +// EffortNone disables thinking. type Effort string const ( + // EffortNone disables reasoning. + EffortNone Effort = "none" + // EffortMinimal represents minimal output effort. + EffortMinimal Effort = "minimal" // EffortLow represents low output effort. EffortLow Effort = "low" // EffortMedium represents medium output effort.