fix(llms): default adaptive reasoning with no effort to high in one place

WithAdaptiveReasoning with an empty effort was handled inconsistently:
the Anthropic and Bedrock paths coerced it to high inline, while OpenAI and
googleai read GetEffort/GetTokens, which returned none/zero and silently
disabled reasoning. The same option produced opposite behavior per provider.

Move the default into GetEffort and GetTokens so every provider resolves an
empty adaptive effort to high, and drop the now-redundant inline coercions
in the three Anthropic paths. Covered by GetEffort/GetTokens table cases and
an OpenAI wire test asserting reasoning_effort=high is sent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-13 12:04:03 +07:00
parent 886d678046
commit 39fde658d6
6 changed files with 68 additions and 15 deletions
+3 -5
View File
@@ -177,15 +177,13 @@ func generateMessagesContent(ctx context.Context, o *LLM, messages []llms.Messag
// flag: adaptive-only generations reject budget thinking and vice versa,
// so honor the caller's preference only where the model accepts it.
if reasoning.ResolveClaudeAdaptive(model, opts.Reasoning.Adaptive) {
effort := opts.Reasoning.Effort
if effort == llms.ReasoningNone {
effort = llms.ReasoningHigh // match the server default instead of sending an empty output_config
}
thinking = &anthropicclient.ThinkingPayload{
Type: "adaptive",
Display: "summarized",
}
outputConfig = &anthropicclient.OutputConfig{Effort: string(effort)}
outputConfig = &anthropicclient.OutputConfig{
Effort: string(opts.Reasoning.GetEffort(opts.GetMaxTokens())),
}
} else {
thinking = &anthropicclient.ThinkingPayload{
Type: "enabled",
@@ -143,10 +143,7 @@ func (c *ConverseClient) buildConverseInput(input *ConverseInput) (*bedrockrunti
if input.ReasoningConfig != nil {
additionalModelFields := converseAdditionalModelRequestFields{}
setAdaptive := func() {
effort := input.ReasoningConfig.Effort
if effort == llms.ReasoningNone {
effort = llms.ReasoningHigh // match the server default instead of sending an empty output_config
}
effort := input.ReasoningConfig.GetEffort(0)
additionalModelFields.Thinking = &converseThinkingPayload{Type: "adaptive", Display: "summarized"}
additionalModelFields.OutputConfig = &converseOutputConfig{Effort: string(effort)}
// Adaptive models reject sampling params.
@@ -670,12 +670,8 @@ func applyAnthropicReasoning(
}
setAdaptive := func() {
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.OutputConfig = &anthropicOutputConfig{Effort: string(cfg.GetEffort(maxTokens))}
input.Temperature = 0
input.TopP = 0
input.TopK = 0
@@ -66,3 +66,37 @@ func TestReasoningEffortPassthroughToWire(t *testing.T) {
})
}
}
// WithAdaptiveReasoning with no explicit effort must not silently disable
// reasoning here: it defaults to high, matching the Anthropic/Bedrock paths.
func TestAdaptiveReasoningNoEffortDefaultsToHighOnWire(t *testing.T) {
t.Parallel()
const completion = `{"id":"x","object":"chat.completion","created":1,"model":"m",` +
`"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],` +
`"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
body = string(b)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, completion)
}))
defer srv.Close()
llm, err := New(WithBaseURL(srv.URL), WithToken("test"), WithModel("reasoning-model"))
if err != nil {
t.Fatalf("New() error: %v", err)
}
if _, err := llm.GenerateContent(
context.Background(),
[]llms.MessageContent{llms.TextParts(llms.ChatMessageTypeHuman, "hi")},
llms.WithAdaptiveReasoning(llms.ReasoningNone),
); err != nil {
t.Fatalf("GenerateContent() error: %v", err)
}
if !strings.Contains(body, `"reasoning_effort":"high"`) {
t.Fatalf("adaptive-no-effort must send reasoning_effort=high, got body: %s", body)
}
}
+11 -1
View File
@@ -91,6 +91,10 @@ func (r *ReasoningConfig) GetEffort(maxTokens int) ReasoningEffort {
return r.Effort
}
if r.Adaptive {
return ReasoningHigh // adaptive with no explicit effort defaults to high
}
if maxTokens <= 0 {
maxTokens = 8192
}
@@ -141,7 +145,13 @@ func (r *ReasoningConfig) GetTokens(maxTokens int) int {
// no distinct token budget for xhigh/max; map them to the high budget.
tokens = max(maxTokens/2, 4096)
case ReasoningNone:
return 0 // disabled
if r.Adaptive {
// adaptive with no explicit effort defaults to the high budget,
// so providers that map reasoning to a token budget don't disable it.
tokens = max(maxTokens/2, 4096)
} else {
return 0 // disabled
}
default:
return -1 // error value to be handled on the server side
}
+18
View File
@@ -596,6 +596,18 @@ func TestReasoningConfig_GetEffort(t *testing.T) {
maxTokens: maxTokens,
expected: llms.ReasoningHigh,
},
{
name: "adaptive with no effort defaults to high",
config: &llms.ReasoningConfig{Adaptive: true},
maxTokens: maxTokens,
expected: llms.ReasoningHigh,
},
{
name: "adaptive with explicit effort keeps it",
config: &llms.ReasoningConfig{Adaptive: true, Effort: llms.ReasoningLow},
maxTokens: maxTokens,
expected: llms.ReasoningLow,
},
{
name: "xhigh effort passes through on the effort path",
config: &llms.ReasoningConfig{Effort: llms.ReasoningXHigh},
@@ -680,6 +692,12 @@ func TestReasoningConfig_GetTokens(t *testing.T) {
maxTokens: maxTokens,
expected: 0,
},
{
name: "adaptive with no effort defaults to high budget",
config: &llms.ReasoningConfig{Adaptive: true},
maxTokens: maxTokens,
expected: max(maxTokens/2, 4096),
},
{
name: "config with explicit tokens",
config: &llms.ReasoningConfig{Tokens: 3000},