diff --git a/internal/reasoning/eino.go b/internal/reasoning/eino.go index 7dbc1306..b27c9f8a 100644 --- a/internal/reasoning/eino.go +++ b/internal/reasoning/eino.go @@ -84,8 +84,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open } } -// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields. -// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget. +// applyClaudeExtendedThinking sets Anthropic Messages API fields per official guidance: +// - Adaptive models (4.6+): thinking.type=adaptive; output_config.effort only when user sets effort (API default is high). +// - Sonnet 3.7: thinking.type=enabled + budget_tokens=10000 (doc example); effort is not mapped — use extra_request_fields for custom budget. func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) { if cfg == nil || mode == "off" { return @@ -93,31 +94,60 @@ func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, if cfg.ExtraFields == nil { cfg.ExtraFields = make(map[string]any) } - if _, exists := cfg.ExtraFields["thinking"]; exists { - return - } m := strings.ToLower(strings.TrimSpace(model)) - thinking := map[string]any{ - "type": "adaptive", - "display": "summarized", + sonnet37 := isClaudeSonnet37(m) + + if _, exists := cfg.ExtraFields["thinking"]; !exists { + cfg.ExtraFields["thinking"] = claudeThinkingForModel(m, sonnet37) } - // Sonnet 3.7: manual extended thinking is the documented path. - if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") { - thinking = map[string]any{ + + applyClaudeOutputConfigEffort(cfg, effort, sonnet37) +} + +// claudeSonnet37DefaultBudgetTokens matches Anthropic extended-thinking documentation examples (budget_tokens with max_tokens 16000). +const claudeSonnet37DefaultBudgetTokens = 10000 + +func isClaudeSonnet37(m string) bool { + return strings.Contains(m, "claude-3-7-sonnet") || + strings.Contains(m, "3-7-sonnet") || + strings.Contains(m, "sonnet-3.7") +} + +func claudeThinkingForModel(m string, sonnet37 bool) map[string]any { + if sonnet37 { + return map[string]any{ "type": "enabled", - "budget_tokens": 10000, + "budget_tokens": claudeSonnet37DefaultBudgetTokens, "display": "summarized", } } - // Opus 4.7+: manual enabled+budget rejected — keep adaptive only. + // Opus 4.7+: manual enabled+budget rejected — adaptive only. if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") { - thinking = map[string]any{ + return map[string]any{ "type": "adaptive", "display": "summarized", } } - _ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place - cfg.ExtraFields["thinking"] = thinking + return map[string]any{ + "type": "adaptive", + "display": "summarized", + } +} + +// applyClaudeOutputConfigEffort sets top-level output_config.effort only when effort is explicitly configured. +// Omitted effort uses the API default (high); do not inject effort on mode:on alone. +func applyClaudeOutputConfigEffort(cfg *einoopenai.ChatModelConfig, effort string, sonnet37 bool) { + if cfg == nil || sonnet37 { + return + } + if _, exists := cfg.ExtraFields["output_config"]; exists { + return + } + e := effortStringForAPI(effort) + if e == "" { + return + } + cfg.ExtraFields["output_config"] = map[string]any{"effort": e} } func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string { diff --git a/internal/reasoning/eino_test.go b/internal/reasoning/eino_test.go index 5f23646f..1a9666e3 100644 --- a/internal/reasoning/eino_test.go +++ b/internal/reasoning/eino_test.go @@ -80,3 +80,80 @@ func TestApplyOpenAICompat_maxPassthrough(t *testing.T) { t.Fatalf("max effort wire=%q, want max", got) } } + +func TestApplyClaude_adaptiveOutputConfigEffort(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{} + oa := &config.OpenAIConfig{ + Provider: "claude", + Model: "claude-opus-4-8", + Reasoning: config.OpenAIReasoningConfig{ + Mode: "on", + Effort: "xhigh", + }, + } + ApplyToEinoChatModelConfig(cfg, oa, nil) + th, ok := cfg.ExtraFields["thinking"].(map[string]any) + if !ok || th["type"] != "adaptive" { + t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"]) + } + oc, ok := cfg.ExtraFields["output_config"].(map[string]any) + if !ok { + t.Fatal("expected output_config") + } + if oc["effort"] != "xhigh" { + t.Fatalf("effort=%v", oc["effort"]) + } +} + +func TestApplyClaude_sonnet37OfficialBudget(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{} + oa := &config.OpenAIConfig{ + Provider: "claude", + Model: "claude-3-7-sonnet-latest", + Reasoning: config.OpenAIReasoningConfig{ + Mode: "on", + Effort: "low", // 3.7 has no output_config.effort; effort is not mapped to budget_tokens + }, + } + ApplyToEinoChatModelConfig(cfg, oa, nil) + th, ok := cfg.ExtraFields["thinking"].(map[string]any) + if !ok || th["type"] != "enabled" { + t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"]) + } + if th["budget_tokens"] != claudeSonnet37DefaultBudgetTokens { + t.Fatalf("budget_tokens=%v, want official example %d", th["budget_tokens"], claudeSonnet37DefaultBudgetTokens) + } + if _, hasOC := cfg.ExtraFields["output_config"]; hasOC { + t.Fatal("sonnet 3.7 should not set output_config") + } +} + +func TestApplyClaude_onWithoutEffortOmitsOutputConfig(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{} + oa := &config.OpenAIConfig{ + Provider: "claude", + Model: "claude-sonnet-4-6", + Reasoning: config.OpenAIReasoningConfig{ + Mode: "on", + }, + } + ApplyToEinoChatModelConfig(cfg, oa, nil) + if _, hasOC := cfg.ExtraFields["output_config"]; hasOC { + t.Fatal("on without explicit effort should omit output_config (API default high)") + } +} + +func TestApplyClaude_autoWithoutEffortSkipsOutputConfig(t *testing.T) { + cfg := &einoopenai.ChatModelConfig{} + oa := &config.OpenAIConfig{ + Provider: "claude", + Model: "claude-sonnet-4-6", + Reasoning: config.OpenAIReasoningConfig{ + Mode: "auto", + }, + } + ApplyToEinoChatModelConfig(cfg, oa, nil) + if _, hasOC := cfg.ExtraFields["output_config"]; hasOC { + t.Fatal("auto without effort should omit output_config") + } +}