diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 647381559b..b03bd9d32c 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -852,7 +852,7 @@ Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `c Subagent spawning is a second source of both values. `spawn_agent` accepts optional `model_config_id` and `reasoning_effort` args (discoverable via the `list_subagent_models` tool): an explicit model selection becomes the child chat's `last_model_config_id` and wins over personal and deployment subagent overrides and over parent inheritance, and an explicit effort is stored on the child's initial message and wins over effort carried by those overrides. Both are validated at spawn time (enabled config, enabled provider, usable credentials, effort on the global scale) and rejected with tool errors before the child chat is created; `computer_use` spawns reject both args because their model routing is specialized. Generation-time resolution and clamping below apply to the child unchanged. -During generation preparation, the effective effort is resolved as the chat's `last_reasoning_effort` if set, else the config's `default`; clamped to the config's `max` on the global scale `none < minimal < low < medium < high < xhigh < max`; and passed through to the provider. The provider verifies whether the configured value is valid for that model at runtime. If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options with `chatprovider.ApplyReasoningEffort` after provider option conversion. For Anthropic, the fantasy provider converts effort into enabled budget thinking on models older than Claude 4.6, which reject adaptive thinking. +During generation preparation, the effective effort is resolved as the chat's `last_reasoning_effort` if set, else the config's `default`; clamped to the config's `max` on the global scale `none < minimal < low < medium < high < xhigh < max`; and passed through to the provider. The provider verifies whether the configured value is valid for that model at runtime. If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options by `chatprovider.ProviderOptionsForCall`, which converts the model config and applies the effort in one step. For Anthropic, the fantasy provider converts effort into enabled budget thinking on models older than Claude 4.6, which reject adaptive thinking. ##### OpenAI transport selection @@ -866,7 +866,9 @@ Request preparation reads the transport from the model instead of recomputing it - Reasoning effort injection creates those option structs when a config has no OpenAI options of its own. - File part conversion (`Model.AcceptsFilePartMediaType`) gates attachments, because the Responses API natively accepts only images and PDFs. A mismatch here drops text attachments. -Paths that build their own clients get a `Model` from the same constructor, including the compaction override, quick generation (used by turn status labels and debug models), and the advisor runtime. Debug recording replaces the wrapped client and preserves the resolved transport. Computer-use turns substitute a hardcoded default model that has no config of its own; it carries its own transport, so the chat model's `openai_config` does not follow it. +The first two happen together in `chatprovider.ProviderOptionsForCall`, the only entry point in `chatprovider` that builds provider options for a call; it delegates transport-aware OpenAI conversion to `chatopenai.ProviderOptionsFromChatConfig`. Config conversion and effort injection cannot pick different option types because one function owns both. + +Paths that build their own clients get a `Model` from the same constructor, including the compaction override, quick generation (used by turn status labels and debug models), and the advisor runtime. Within quick generation, only title generation converts the model config through `ProviderOptionsForCall`; the turn status label and chat summary paths deliberately send no provider options, because they are short structured calls that set their own output bounds. Debug recording replaces the wrapped client and preserves the resolved transport. Computer-use turns substitute a hardcoded default model that has no config of its own; it carries its own transport, so the chat model's `openai_config` does not follow it. Azure is deliberately exempt: its provider always enables the Responses API for known models and exposes no equivalent per-model hook, so the transport keeps following the known-model list for Azure. Ignoring the override there is what keeps the decisions above in agreement with the Azure client. The exemption is narrower than it appears, because chatd never builds an azure-typed provider as a fantasy azure client: `fantasyConfigForAIBridge` folds every provider type other than anthropic, bedrock, and openai into openai-compat, which always speaks Chat Completions. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ae57548d0d..3678f8e565 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -424,19 +424,7 @@ func (p *Server) newAdvisorRuntime( advisorCallConfig.MaxOutputTokens = ptr.Ref(maxOutputTokens) // The override resolver pins an explicit advisor effort into the model // config. Fallback models keep their configured default effort. - advisorReasoningEffort := chatprovider.ResolveReasoningEffort( - nil, - advisorCallConfig.ReasoningEffort, - ) - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( - advisorModel, - advisorCallConfig.ProviderOptions, - ) - providerOptions = chatprovider.ApplyReasoningEffort( - advisorModel, - providerOptions, - advisorReasoningEffort, - ) + providerOptions := chatprovider.ProviderOptionsForCall(advisorModel, advisorCallConfig, nil) rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{ Model: advisorModel.LanguageModel(), diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 2ee273a233..e0db160111 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -1109,9 +1109,22 @@ func missingProviderAPIKeyError(provider string) error { } } -// ProviderOptionsFromChatModelConfig converts chat model provider options to -// fantasy provider options used for inference calls. -func ProviderOptionsFromChatModelConfig( +// ProviderOptionsForCall builds the provider options for one inference call. +// Config conversion and reasoning effort both create OpenAI option structs, so +// owning them together is what keeps their type aligned with the model's +// transport. requestedEffort is the caller's per-turn choice, which the +// config's bounds clamp. +func ProviderOptionsForCall( + model Model, + config codersdk.ChatModelCallConfig, + requestedEffort *string, +) fantasy.ProviderOptions { + options := providerOptionsFromChatModelConfig(model, config.ProviderOptions) + effort := ResolveReasoningEffort(requestedEffort, config.ReasoningEffort) + return applyReasoningEffort(model, options, effort) +} + +func providerOptionsFromChatModelConfig( model Model, options *codersdk.ChatModelProviderOptions, ) fantasy.ProviderOptions { diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index d5c8cc723e..f598a01f31 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -387,14 +387,16 @@ func TestAnthropicThinkingDisplayFromChat(t *testing.T) { } } -func TestProviderOptionsFromChatModelConfig_AnthropicThinkingDisplay(t *testing.T) { +func TestProviderOptionsForCall_AnthropicThinkingDisplay(t *testing.T) { t.Parallel() - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(chatprovider.Model{}, &codersdk.ChatModelProviderOptions{ - Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ - ThinkingDisplay: ptr.Ref(" SUMMARIZED "), + providerOptions := chatprovider.ProviderOptionsForCall(chatprovider.Model{}, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ + ThinkingDisplay: ptr.Ref(" SUMMARIZED "), + }, }, - }) + }, nil) require.NotNil(t, providerOptions) anthropicOptions, ok := providerOptions[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 50e78923e3..52e8a01606 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -87,7 +87,7 @@ func SelectableReasoningEfforts( return values[:maxRank+1] } -func ApplyReasoningEffort( +func applyReasoningEffort( model Model, options fantasy.ProviderOptions, effort *string, diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go new file mode 100644 index 0000000000..b7b4110a58 --- /dev/null +++ b/coderd/x/chatd/chatprovider/reasoningeffort_internal_test.go @@ -0,0 +1,170 @@ +//nolint:testpackage // These tests cover the unexported applyReasoningEffort. +package chatprovider + +import ( + "testing" + + "charm.land/fantasy" + fantasyanthropic "charm.land/fantasy/providers/anthropic" + fantasyopenai "charm.land/fantasy/providers/openai" + fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" + fantasyopenrouter "charm.land/fantasy/providers/openrouter" + fantasyvercel "charm.land/fantasy/providers/vercel" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" +) + +func TestApplyReasoningEffort(t *testing.T) { + t.Parallel() + + t.Run("CreatesOpenAIResponsesEntry", func(t *testing.T) { + t.Parallel() + + got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil), nil, new(codersdk.ChatModelReasoningEffortHigh)) + providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.True(t, ok, "%T", got[fantasyopenai.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) + + t.Run("PreservesOpenAIResponsesEntry", func(t *testing.T) { + t.Parallel() + + options := fantasy.ProviderOptions{ + fantasyopenai.Name: &fantasyopenai.ResponsesProviderOptions{ + Instructions: ptr.Ref("answer briefly"), + Store: ptr.Ref(true), + }, + } + got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil), options, new(codersdk.ChatModelReasoningEffortHigh)) + providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.True(t, ok, "%T", got[fantasyopenai.Name]) + require.Same(t, options[fantasyopenai.Name], providerOptions) + require.Equal(t, "answer briefly", *providerOptions.Instructions) + require.True(t, *providerOptions.Store) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) + + t.Run("PreservesOpenAILegacyEntry", func(t *testing.T) { + t.Parallel() + + options := fantasy.ProviderOptions{ + fantasyopenai.Name: &fantasyopenai.ProviderOptions{ + User: ptr.Ref("user"), + ParallelToolCalls: ptr.Ref(true), + }, + } + got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, nil), options, new(codersdk.ChatModelReasoningEffortHigh)) + providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenai.Name]) + require.Same(t, options[fantasyopenai.Name], providerOptions) + require.Equal(t, "user", *providerOptions.User) + require.True(t, *providerOptions.ParallelToolCalls) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) + + tests := []struct { + name string + provider string + options fantasy.ProviderOptions + assert func(*testing.T, fantasy.ProviderOptions) + }{ + { + name: "CreatesAnthropicEntry", + provider: fantasyanthropic.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) + require.True(t, ok, "%T", got[fantasyanthropic.Name]) + require.NotNil(t, providerOptions.Effort) + require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) + }, + }, + { + name: "PreservesAnthropicEntry", + provider: fantasyanthropic.Name, + options: fantasy.ProviderOptions{fantasyanthropic.Name: &fantasyanthropic.ProviderOptions{SendReasoning: ptr.Ref(true)}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) + require.True(t, *providerOptions.SendReasoning) + require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) + }, + }, + { + name: "CreatesOpenAICompatEntry", + provider: fantasyopenaicompat.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenaicompat.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }, + }, + { + name: "PreservesOpenAICompatEntry", + provider: fantasyopenaicompat.Name, + options: fantasy.ProviderOptions{fantasyopenaicompat.Name: &fantasyopenaicompat.ProviderOptions{User: ptr.Ref("user")}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) + require.Equal(t, "user", *providerOptions.User) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }, + }, + { + name: "CreatesVercelEntry", + provider: fantasyvercel.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyvercel.Name].(*fantasyvercel.ProviderOptions) + require.True(t, ok, "%T", got[fantasyvercel.Name]) + require.NotNil(t, providerOptions.Reasoning) + require.NotNil(t, providerOptions.Reasoning.Effort) + require.Equal(t, fantasyvercel.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + { + name: "PreservesVercelNestedEntry", + provider: fantasyvercel.Name, + options: fantasy.ProviderOptions{fantasyvercel.Name: &fantasyvercel.ProviderOptions{Reasoning: &fantasyvercel.ReasoningOptions{Enabled: ptr.Ref(true), MaxTokens: ptr.Ref(int64(1024))}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasyvercel.Name].(*fantasyvercel.ProviderOptions) + require.True(t, *providerOptions.Reasoning.Enabled) + require.Equal(t, int64(1024), *providerOptions.Reasoning.MaxTokens) + require.Equal(t, fantasyvercel.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + { + name: "CreatesOpenRouterEntry", + provider: fantasyopenrouter.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenrouter.Name]) + require.NotNil(t, providerOptions.Reasoning) + require.NotNil(t, providerOptions.Reasoning.Effort) + require.Equal(t, fantasyopenrouter.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + { + name: "PreservesOpenRouterNestedEntry", + provider: fantasyopenrouter.Name, + options: fantasy.ProviderOptions{fantasyopenrouter.Name: &fantasyopenrouter.ProviderOptions{Reasoning: &fantasyopenrouter.ReasoningOptions{Enabled: ptr.Ref(true), MaxTokens: ptr.Ref(int64(1024))}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenrouter.Name]) + require.True(t, *providerOptions.Reasoning.Enabled) + require.Equal(t, int64(1024), *providerOptions.Reasoning.MaxTokens) + require.NotNil(t, providerOptions.Reasoning.Effort) + require.Equal(t, fantasyopenrouter.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := applyReasoningEffort(NewModel(&chattest.FakeModel{ProviderName: tt.provider}, nil), tt.options, new(codersdk.ChatModelReasoningEffortHigh)) + tt.assert(t, got) + }) + } +} diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_test.go index d4bdeef5d8..f0a00c358d 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_test.go @@ -3,17 +3,10 @@ package chatprovider_test import ( "testing" - "charm.land/fantasy" - fantasyanthropic "charm.land/fantasy/providers/anthropic" - fantasyopenai "charm.land/fantasy/providers/openai" - fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" - fantasyopenrouter "charm.land/fantasy/providers/openrouter" - fantasyvercel "charm.land/fantasy/providers/vercel" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" - "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/codersdk" ) @@ -79,158 +72,6 @@ func TestSelectableReasoningEfforts(t *testing.T) { } } -func TestApplyReasoningEffort(t *testing.T) { - t.Parallel() - - t.Run("CreatesOpenAIResponsesEntry", func(t *testing.T) { - t.Parallel() - - got := chatprovider.ApplyReasoningEffort(chatprovider.NewModel(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil), nil, new(codersdk.ChatModelReasoningEffortHigh)) - providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) - require.True(t, ok, "%T", got[fantasyopenai.Name]) - require.NotNil(t, providerOptions.ReasoningEffort) - require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) - }) - - t.Run("PreservesOpenAIResponsesEntry", func(t *testing.T) { - t.Parallel() - - options := fantasy.ProviderOptions{ - fantasyopenai.Name: &fantasyopenai.ResponsesProviderOptions{ - Instructions: ptr.Ref("answer briefly"), - Store: ptr.Ref(true), - }, - } - got := chatprovider.ApplyReasoningEffort(chatprovider.NewModel(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil), options, new(codersdk.ChatModelReasoningEffortHigh)) - providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) - require.True(t, ok, "%T", got[fantasyopenai.Name]) - require.Same(t, options[fantasyopenai.Name], providerOptions) - require.Equal(t, "answer briefly", *providerOptions.Instructions) - require.True(t, *providerOptions.Store) - require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) - }) - - t.Run("PreservesOpenAILegacyEntry", func(t *testing.T) { - t.Parallel() - - options := fantasy.ProviderOptions{ - fantasyopenai.Name: &fantasyopenai.ProviderOptions{ - User: ptr.Ref("user"), - ParallelToolCalls: ptr.Ref(true), - }, - } - got := chatprovider.ApplyReasoningEffort(chatprovider.NewModel(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, nil), options, new(codersdk.ChatModelReasoningEffortHigh)) - providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions) - require.True(t, ok, "%T", got[fantasyopenai.Name]) - require.Same(t, options[fantasyopenai.Name], providerOptions) - require.Equal(t, "user", *providerOptions.User) - require.True(t, *providerOptions.ParallelToolCalls) - require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) - }) - - tests := []struct { - name string - provider string - options fantasy.ProviderOptions - assert func(*testing.T, fantasy.ProviderOptions) - }{ - { - name: "CreatesAnthropicEntry", - provider: fantasyanthropic.Name, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions, ok := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) - require.True(t, ok, "%T", got[fantasyanthropic.Name]) - require.NotNil(t, providerOptions.Effort) - require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) - }, - }, - { - name: "PreservesAnthropicEntry", - provider: fantasyanthropic.Name, - options: fantasy.ProviderOptions{fantasyanthropic.Name: &fantasyanthropic.ProviderOptions{SendReasoning: ptr.Ref(true)}}, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) - require.True(t, *providerOptions.SendReasoning) - require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) - }, - }, - { - name: "CreatesOpenAICompatEntry", - provider: fantasyopenaicompat.Name, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions, ok := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) - require.True(t, ok, "%T", got[fantasyopenaicompat.Name]) - require.NotNil(t, providerOptions.ReasoningEffort) - require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) - }, - }, - { - name: "PreservesOpenAICompatEntry", - provider: fantasyopenaicompat.Name, - options: fantasy.ProviderOptions{fantasyopenaicompat.Name: &fantasyopenaicompat.ProviderOptions{User: ptr.Ref("user")}}, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) - require.Equal(t, "user", *providerOptions.User) - require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) - }, - }, - { - name: "CreatesVercelEntry", - provider: fantasyvercel.Name, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions, ok := got[fantasyvercel.Name].(*fantasyvercel.ProviderOptions) - require.True(t, ok, "%T", got[fantasyvercel.Name]) - require.NotNil(t, providerOptions.Reasoning) - require.NotNil(t, providerOptions.Reasoning.Effort) - require.Equal(t, fantasyvercel.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) - }, - }, - { - name: "PreservesVercelNestedEntry", - provider: fantasyvercel.Name, - options: fantasy.ProviderOptions{fantasyvercel.Name: &fantasyvercel.ProviderOptions{Reasoning: &fantasyvercel.ReasoningOptions{Enabled: ptr.Ref(true), MaxTokens: ptr.Ref(int64(1024))}}}, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions := got[fantasyvercel.Name].(*fantasyvercel.ProviderOptions) - require.True(t, *providerOptions.Reasoning.Enabled) - require.Equal(t, int64(1024), *providerOptions.Reasoning.MaxTokens) - require.Equal(t, fantasyvercel.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) - }, - }, - { - name: "CreatesOpenRouterEntry", - provider: fantasyopenrouter.Name, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions) - require.True(t, ok, "%T", got[fantasyopenrouter.Name]) - require.NotNil(t, providerOptions.Reasoning) - require.NotNil(t, providerOptions.Reasoning.Effort) - require.Equal(t, fantasyopenrouter.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) - }, - }, - { - name: "PreservesOpenRouterNestedEntry", - provider: fantasyopenrouter.Name, - options: fantasy.ProviderOptions{fantasyopenrouter.Name: &fantasyopenrouter.ProviderOptions{Reasoning: &fantasyopenrouter.ReasoningOptions{Enabled: ptr.Ref(true), MaxTokens: ptr.Ref(int64(1024))}}}, - assert: func(t *testing.T, got fantasy.ProviderOptions) { - providerOptions, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions) - require.True(t, ok, "%T", got[fantasyopenrouter.Name]) - require.True(t, *providerOptions.Reasoning.Enabled) - require.Equal(t, int64(1024), *providerOptions.Reasoning.MaxTokens) - require.NotNil(t, providerOptions.Reasoning.Effort) - require.Equal(t, fantasyopenrouter.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := chatprovider.ApplyReasoningEffort(chatprovider.NewModel(&chattest.FakeModel{ProviderName: tt.provider}, nil), tt.options, new(codersdk.ChatModelReasoningEffortHigh)) - tt.assert(t, got) - }) - } -} - func effortConfig(defaultEffort, maxEffort string) *codersdk.ChatModelReasoningEffortConfig { cfg := &codersdk.ChatModelReasoningEffortConfig{} if defaultEffort != "" { diff --git a/coderd/x/chatd/chatprovider/responses_api_test.go b/coderd/x/chatd/chatprovider/responses_api_test.go index ea1423370d..f6c29534bc 100644 --- a/coderd/x/chatd/chatprovider/responses_api_test.go +++ b/coderd/x/chatd/chatprovider/responses_api_test.go @@ -9,6 +9,7 @@ import ( fantasyopenai "charm.land/fantasy/providers/openai" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/codersdk" @@ -165,16 +166,21 @@ func TestModelTransportConsumersAgree(t *testing.T) { require.Equal(t, tc.wantPath, gotPath) mu.Unlock() - options := chatprovider.ProviderOptionsFromChatModelConfig(model, &codersdk.ChatModelProviderOptions{ - OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier}, - }) + options := chatprovider.ProviderOptionsForCall(model, codersdk.ChatModelCallConfig{ + ProviderOptions: &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier}, + }, + }, nil) require.IsType(t, tc.wantOptions, options[fantasyopenai.Name]) - effortOptions := chatprovider.ApplyReasoningEffort( - model, - nil, - new(codersdk.ChatModelReasoningEffortHigh), - ) + // Reasoning effort creates the option struct when the config has no + // OpenAI options of its own. + effortOptions := chatprovider.ProviderOptionsForCall(model, codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }, nil) require.IsType(t, tc.wantOptions, effortOptions[fantasyopenai.Name]) require.Equal(t, tc.wantAcceptText, model.AcceptsFilePartMediaType("text/plain")) diff --git a/coderd/x/chatd/compaction_override.go b/coderd/x/chatd/compaction_override.go index 3e2ec34386..fc764ab58d 100644 --- a/coderd/x/chatd/compaction_override.go +++ b/coderd/x/chatd/compaction_override.go @@ -177,17 +177,5 @@ func compactionOverrideProviderOptions( ) } } - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( - model, - callConfig.ProviderOptions, - ) - reasoningEffort := chatprovider.ResolveReasoningEffort( - nil, - callConfig.ReasoningEffort, - ) - return chatprovider.ApplyReasoningEffort( - model, - providerOptions, - reasoningEffort, - ), nil + return chatprovider.ProviderOptionsForCall(model, callConfig, nil), nil } diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index c40b9825a8..9acf7f3823 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -542,19 +542,7 @@ func (server *Server) prepareGeneration( if chat.LastReasoningEffort.Valid { requestedEffort = new(string(chat.LastReasoningEffort.ChatReasoningEffort)) } - reasoningEffort := chatprovider.ResolveReasoningEffort( - requestedEffort, - callConfig.ReasoningEffort, - ) - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( - model, - callConfig.ProviderOptions, - ) - providerOptions = chatprovider.ApplyReasoningEffort( - model, - providerOptions, - reasoningEffort, - ) + providerOptions := chatprovider.ProviderOptionsForCall(model, callConfig, requestedEffort) activeToolNames := activeToolNamesForTurn(tools, currentPlanMode, chat.ParentChatID, approvedPlanMCPConfigIDs) if isExploreSubagent { diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 653a830583..ccc73b3ed3 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -425,15 +425,7 @@ func (p *Server) titleGenerationProviderOptions( ) } } - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( - model, - callConfig.ProviderOptions, - ) - return chatprovider.ApplyReasoningEffort( - model, - providerOptions, - chatprovider.ResolveReasoningEffort(nil, callConfig.ReasoningEffort), - ) + return chatprovider.ProviderOptionsForCall(model, callConfig, nil) } func (p *Server) newQuickgenDebugModel(