diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 7b15178142ede..e71c48de133d9 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7469,7 +7469,19 @@ func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) erro } } - return nil + return validateChatModelProviderOptions(modelConfig.ProviderOptions) +} + +func validateChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) error { + if options == nil || options.Anthropic == nil || options.Anthropic.ThinkingDisplay == nil { + return nil + } + + if strings.TrimSpace(*options.Anthropic.ThinkingDisplay) == "" || + chatprovider.AnthropicThinkingDisplayFromChat(options.Anthropic.ThinkingDisplay) != nil { + return nil + } + return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted") } func validateNonNegativeDecimalField(name string, value *decimal.Decimal) error { diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index bfa4dc6242455..93d22bd7f4163 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -9,6 +9,42 @@ import ( "github.com/coder/coder/v2/codersdk" ) +func TestValidateChatModelProviderOptions_AnthropicThinkingDisplay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + display string + wantErr string + }{ + {name: "Summarized", display: "summarized"}, + {name: "Omitted", display: " omitted "}, + {name: "Empty", display: " "}, + { + name: "Invalid", + display: "summrized", + wantErr: "provider_options.anthropic.thinking_display must be one of summarized, omitted", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + display := tt.display + err := validateChatModelProviderOptions(&codersdk.ChatModelProviderOptions{ + Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ + ThinkingDisplay: &display, + }, + }) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + func TestValidateChatModelConfigProviderModel(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index ac817e094034e..545fb71a2e8a9 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -771,6 +771,30 @@ func ReasoningEffortFromChat(provider string, value *string) *string { } } +// AnthropicThinkingDisplayFromChat normalizes chat-config thinking display +// values for Anthropic and returns the canonical provider display value. +func AnthropicThinkingDisplayFromChat(value *string) *fantasyanthropic.ThinkingDisplay { + if value == nil { + return nil + } + + normalized := strings.ToLower(strings.TrimSpace(*value)) + if normalized == "" { + return nil + } + + display := chatutil.NormalizedEnumValue( + normalized, + string(fantasyanthropic.ThinkingDisplaySummarized), + string(fantasyanthropic.ThinkingDisplayOmitted), + ) + if display == nil { + return nil + } + valueCopy := fantasyanthropic.ThinkingDisplay(*display) + return &valueCopy +} + // MergeMissingModelCostConfig fills unset pricing metadata from defaults. func MergeMissingModelCostConfig( dst **codersdk.ModelCostConfig, @@ -919,6 +943,9 @@ func MergeMissingProviderOptions( if dstAnthropic.Effort == nil { dstAnthropic.Effort = defaultAnthropic.Effort } + if dstAnthropic.ThinkingDisplay == nil { + dstAnthropic.ThinkingDisplay = defaultAnthropic.ThinkingDisplay + } if dstAnthropic.DisableParallelToolUse == nil { dstAnthropic.DisableParallelToolUse = defaultAnthropic.DisableParallelToolUse } @@ -1408,6 +1435,7 @@ func anthropicProviderOptionsFromChatConfig( result := &fantasyanthropic.ProviderOptions{ SendReasoning: options.SendReasoning, Effort: anthropicEffortFromChat(options.Effort), + ThinkingDisplay: AnthropicThinkingDisplayFromChat(options.ThinkingDisplay), DisableParallelToolUse: options.DisableParallelToolUse, } if options.Thinking != nil && options.Thinking.BudgetTokens != nil { diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 0e851d3f89450..80911d89cd174 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -371,6 +371,77 @@ func TestReasoningEffortFromChat(t *testing.T) { } } +func TestAnthropicThinkingDisplayFromChat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input *string + want *fantasyanthropic.ThinkingDisplay + }{ + { + name: "Summarized", + input: ptr.Ref(" SUMMARIZED "), + want: ptr.Ref(fantasyanthropic.ThinkingDisplaySummarized), + }, + { + name: "Omitted", + input: ptr.Ref("omitted"), + want: ptr.Ref(fantasyanthropic.ThinkingDisplayOmitted), + }, + { + name: "InvalidReturnsNil", + input: ptr.Ref("summary"), + }, + { + name: "NilInputReturnsNil", + input: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := chatprovider.AnthropicThinkingDisplayFromChat(tt.input) + require.Equal(t, tt.want, got) + }) + } +} + +func TestProviderOptionsFromChatModelConfig_AnthropicThinkingDisplay(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(nil, &codersdk.ChatModelProviderOptions{ + Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ + ThinkingDisplay: ptr.Ref(" SUMMARIZED "), + }, + }) + + require.NotNil(t, providerOptions) + anthropicOptions, ok := providerOptions[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) + require.True(t, ok) + require.NotNil(t, anthropicOptions.ThinkingDisplay) + require.Equal(t, fantasyanthropic.ThinkingDisplaySummarized, *anthropicOptions.ThinkingDisplay) +} + +func TestMergeMissingProviderOptions_AnthropicThinkingDisplay(t *testing.T) { + t.Parallel() + + options := &codersdk.ChatModelProviderOptions{ + Anthropic: &codersdk.ChatModelAnthropicProviderOptions{}, + } + defaults := &codersdk.ChatModelProviderOptions{ + Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ + ThinkingDisplay: ptr.Ref("summarized"), + }, + } + + chatprovider.MergeMissingProviderOptions(&options, defaults) + + require.NotNil(t, options.Anthropic.ThinkingDisplay) + require.Equal(t, "summarized", *options.Anthropic.ThinkingDisplay) +} + func TestResolveUserProviderKeys_UnavailableReason(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index 8770368a3db89..7c860cf424a4b 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1229,6 +1229,7 @@ type ChatModelAnthropicProviderOptions struct { SendReasoning *bool `json:"send_reasoning,omitempty" description:"Whether to include reasoning content in the response"` Thinking *ChatModelAnthropicThinkingOptions `json:"thinking,omitempty" description:"Configuration for extended thinking"` Effort *string `json:"effort,omitempty" label:"Reasoning Effort" description:"Controls the level of reasoning effort" enum:"low,medium,high,xhigh,max"` + ThinkingDisplay *string `json:"thinking_display,omitempty" label:"Thinking Display" description:"Controls how Anthropic returns thinking content" enum:"summarized,omitted"` DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty" description:"Whether to disable parallel tool execution"` WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"` AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains (cannot be used with blocked_domains)"` diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index f169590050791..5c6201ac7a056 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -24,11 +24,13 @@ func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testin sendReasoning := true effort := "high" + thinkingDisplay := "summarized" raw, err := json.Marshal(codersdk.ChatModelProviderOptions{ Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ - SendReasoning: &sendReasoning, - Effort: &effort, + SendReasoning: &sendReasoning, + Effort: &effort, + ThinkingDisplay: &thinkingDisplay, }, }) require.NoError(t, err) @@ -36,6 +38,7 @@ func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testin require.NotContains(t, string(raw), `"data":`) require.Contains(t, string(raw), `"send_reasoning":true`) require.Contains(t, string(raw), `"effort":"high"`) + require.Contains(t, string(raw), `"thinking_display":"summarized"`) } func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *testing.T) { @@ -44,7 +47,8 @@ func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *t raw := []byte(`{ "anthropic": { "send_reasoning": true, - "effort": "high" + "effort": "high", + "thinking_display": "summarized" } }`) @@ -60,6 +64,8 @@ func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *t "high", *decoded.Anthropic.Effort, ) + require.NotNil(t, decoded.Anthropic.ThinkingDisplay) + require.Equal(t, "summarized", *decoded.Anthropic.ThinkingDisplay) } func TestChatUsageLimitExceededFrom(t *testing.T) { diff --git a/go.mod b/go.mod index cf9611567939b..691d1bb3e7f43 100644 --- a/go.mod +++ b/go.mod @@ -94,8 +94,9 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 // emit a Base64 PDF document block for application/pdf FileParts on the // Anthropic provider so user-uploaded PDFs actually reach Claude/Bedrock // instead of being silently dropped. -// See: https://github.com/coder/fantasy/commits/7d46e640327a -replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260602023814-7d46e640327a +// 11) coder/fantasy#39, support Anthropic thinking_display natively. +// See: https://github.com/coder/fantasy/commits/a2a3f2171ec8 +replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260604204802-a2a3f2171ec8 // coder/coder uses a fork of charmbracelet's fork of the Anthropic Go SDK // with performance improvements and Bedrock header cleanup. diff --git a/go.sum b/go.sum index 4c43780c6a5ec..cc59a2c4e9780 100644 --- a/go.sum +++ b/go.sum @@ -324,8 +324,8 @@ github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwu github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4= github.com/coder/clistat v1.2.1 h1:P9/10njXMyj5cWzIU5wkRsSy5LVQH49+tcGMsAgWX0w= github.com/coder/clistat v1.2.1/go.mod h1:m7SC0uj88eEERgvF8Kn6+w6XF21BeSr+15f7GoLAw0A= -github.com/coder/fantasy v0.0.0-20260602023814-7d46e640327a h1:ffQixHAwjJLHgFfe4rtrAsFNRGhEyWnBSpInnLIxDPo= -github.com/coder/fantasy v0.0.0-20260602023814-7d46e640327a/go.mod h1:wZ0e3lEPqrM0XiIdAUQLvMKCLYhc3gi96MRX2wjbX44= +github.com/coder/fantasy v0.0.0-20260604204802-a2a3f2171ec8 h1:+8QmiW3qKSqS4pkEQQbK7Rg3UGWnD/c5BXp1tPpX1sU= +github.com/coder/fantasy v0.0.0-20260604204802-a2a3f2171ec8/go.mod h1:RdKpE+blFnbGx4XmNc952AXAdBL1ZXg9iTnXHjdn9Bk= github.com/coder/flog v1.1.0 h1:kbAes1ai8fIS5OeV+QAnKBQE22ty1jRF/mcAwHpLBa4= github.com/coder/flog v1.1.0/go.mod h1:UQlQvrkJBvnRGo69Le8E24Tcl5SJleAAR7gYEHzAmdQ= github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322 h1:m0lPZjlQ7vdVpRBPKfYIFlmgevoTkBxB10wv6l2gOaU= diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index 8af34d6d2c2f0..d64f1f22e7ca8 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -112,6 +112,16 @@ "enum": ["low", "medium", "high", "xhigh", "max"], "input_type": "select" }, + { + "json_name": "thinking_display", + "go_name": "ThinkingDisplay", + "type": "string", + "description": "Controls how Anthropic returns thinking content", + "label": "Thinking Display", + "required": false, + "enum": ["summarized", "omitted"], + "input_type": "select" + }, { "json_name": "disable_parallel_tool_use", "go_name": "DisableParallelToolUse", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2f3f46e6794d1..b57f604d286c6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2295,6 +2295,7 @@ export interface ChatModelAnthropicProviderOptions { readonly send_reasoning?: boolean; readonly thinking?: ChatModelAnthropicThinkingOptions; readonly effort?: string; + readonly thinking_display?: string; readonly disable_parallel_tool_use?: boolean; readonly web_search_enabled?: boolean; readonly allowed_domains?: readonly string[];