diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index a6f54bf80d33b..3360c583971a4 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -8179,10 +8179,15 @@ func isZeroChatModelCallConfig(config *codersdk.ChatModelCallConfig) bool { config.PresencePenalty == nil && config.FrequencyPenalty == nil && config.ReasoningEffort == nil && + isZeroChatModelOpenAIConfig(config.OpenAIConfig) && isZeroModelCostConfig(config.Cost) && isZeroChatModelProviderOptions(config.ProviderOptions) } +func isZeroChatModelOpenAIConfig(config *codersdk.ChatModelOpenAIConfig) bool { + return config == nil || config.UseResponsesAPI == nil +} + func isZeroModelCostConfig(cost *codersdk.ModelCostConfig) bool { if cost == nil { return true diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index bcd7624438ecd..7ac34ff850bc4 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -5,10 +5,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "testing" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/shopspring/decimal" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" @@ -19,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -371,3 +374,47 @@ func TestRewriteChatStartWorkspaceManualUpdateResponse(t *testing.T) { }) } } + +// Every ChatModelCallConfig field must classify a config as non-zero when set, +// or unmarshalChatModelCallConfig hides it from API responses while the stored +// value stays active. Fails when a new field is added without a sample here. +func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) { + t.Parallel() + + costSample := decimal.NewFromInt(3) + sampled := codersdk.ChatModelCallConfig{ + MaxOutputTokens: ptr.Ref(int64(4096)), + Temperature: ptr.Ref(0.7), + TopP: ptr.Ref(0.9), + TopK: ptr.Ref(int64(40)), + PresencePenalty: ptr.Ref(0.1), + FrequencyPenalty: ptr.Ref(0.2), + Cost: &codersdk.ModelCostConfig{ + InputPricePerMillionTokens: &costSample, + }, + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("medium"), + }, + OpenAIConfig: &codersdk.ChatModelOpenAIConfig{ + UseResponsesAPI: ptr.Ref(true), + }, + ProviderOptions: &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{}, + }, + } + + require.True(t, isZeroChatModelCallConfig(nil)) + require.True(t, isZeroChatModelCallConfig(&codersdk.ChatModelCallConfig{})) + + sampledValue := reflect.ValueOf(sampled) + for i := 0; i < sampledValue.NumField(); i++ { + field := sampledValue.Type().Field(i) + require.Falsef(t, sampledValue.Field(i).IsZero(), + "field %s needs a non-zero sample value", field.Name) + + config := &codersdk.ChatModelCallConfig{} + reflect.ValueOf(config).Elem().Field(i).Set(sampledValue.Field(i)) + require.Falsef(t, isZeroChatModelCallConfig(config), + "isZeroChatModelCallConfig ignores field %s", field.Name) + } +} diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index f2bfab782a6ad..4faf6e10155cd 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -854,6 +854,51 @@ Subagent spawning is a second source of both values. `spawn_agent` accepts optio 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. +##### OpenAI transport selection + +OpenAI models speak either the Responses API or Chat Completions. The provider SDK picks per model from a static known-model list, so a newly released model absent from that list falls back to Chat Completions. Model configs may override the choice with `openai_config.use_responses_api` inside `chat_model_configs.options`: unset keeps the known-model list, true forces Responses, false forces Chat Completions. It sits in `openai_config` rather than `provider_options.openai` because it is applied once when the client is built, while `provider_options` holds per-request parameters. + +The transport is decided in more than one place, and those decisions must agree with the client that was built. `chatopenai.UsesResponsesAPI` is the single predicate, and every path that builds an OpenAI client must pass the same override to both the client and the code that prepares its requests: + +- Client construction (`ModelFromConfig`) installs the override as the SDK's per-model transport hook. The hook only selects among transports the client enables, so it cannot turn on Responses for a provider whose client was not built to allow it. +- Provider option conversion (`UsesResponsesOptions`) chooses between the Responses and Chat Completions option structs. The SDK type-asserts the concrete struct, so a mismatch silently discards every OpenAI provider option rather than failing. +- File part conversion (`AcceptsFilePartMediaType`) gates attachments, because the Responses API natively accepts only images and PDFs. A mismatch here silently drops text attachments. + +Paths that build their own clients must thread the override too, including the compaction override, quick generation (used by turn status labels and debug models), and the advisor runtime. + +Azure is deliberately exempt: its provider always enables the Responses API for known models and exposes no equivalent per-model hook, so `UsesResponsesAPI` 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. + +Both transports read the same `provider_options.openai` config, but not every field applies to both wire formats. The table below records, per field, which transport honors it; `TestProviderOptionsTransportParity` fails when a field is honored on one transport and silently ignored on the other without being recorded there as intentional. + +| `provider_options.openai` field | Responses | Chat Completions | +| --- | --- | --- | +| `include` | yes | no | +| `instructions` | yes | no | +| `logit_bias` | no | yes | +| `log_probs` | yes | yes | +| `top_log_probs` | yes | yes | +| `max_tool_calls` | yes | no | +| `parallel_tool_calls` | yes | yes | +| `user` | yes | yes | +| `reasoning_summary` | yes | no | +| `max_completion_tokens` | no | yes | +| `text_verbosity` | yes | yes | +| `prediction` | no | yes | +| `store` | yes | yes | +| `metadata` | yes | yes | +| `prompt_cache_key` | yes | yes | +| `safety_identifier` | yes | yes | +| `service_tier` | yes | yes | +| `structured_outputs` | no | yes | +| `strict_json_schema` | yes | no | +| `web_search_enabled` | no | no | +| `search_context_size` | no | no | +| `allowed_domains` | no | no | + +Three asymmetries are deliberate near-equivalents rather than gaps. `max_completion_tokens` is the Chat Completions cap; on Responses the transport-neutral `max_output_tokens` config bounds output instead. `structured_outputs` and `strict_json_schema` are the per-API strictness switches, each honored only by its own API. On Responses, `top_log_probs` wins over `log_probs` because that API takes a single logprobs value. The trailing web search fields configure tool wiring rather than per-request provider options, so neither transport reads them during option conversion. + +The model editor scopes the field to openai-typed providers with a `providers` struct tag, which the option schema generator emits as `visible_for_providers`. Gating on the raw provider type rather than the alias table keeps the control out of editors for provider types that cannot honor it. + #### Compaction model selection Compaction is an auxiliary LLM call: when the conversation approaches the context limit, the generation goroutine asks a model to summarize the history, commits the summary as a compressed boundary, and continues the turn on the chat model. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ccc12ad80c4c4..6948c37310b37 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -428,14 +428,17 @@ func (p *Server) newAdvisorRuntime( nil, advisorCallConfig.ReasoningEffort, ) + advisorResponsesOverride := chatprovider.OpenAIResponsesAPIOverride(advisorCallConfig.OpenAIConfig) providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( advisorModel, advisorCallConfig.ProviderOptions, + advisorResponsesOverride, ) providerOptions = chatprovider.ApplyReasoningEffort( advisorModel, providerOptions, advisorReasoningEffort, + advisorResponsesOverride, ) rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{ @@ -3501,6 +3504,7 @@ type runChatResult struct { FallbackRoute aiGatewayModelRoute FallbackModel string ModelBuildOptions modelBuildOptions + StatusLabelOptions json.RawMessage TriggerMessageID int64 HistoryTipMessageID int64 } @@ -4690,6 +4694,7 @@ func (p *Server) generateFinalTurnStatusLabel( runResult.StatusLabelModel, runResult.FallbackRoute, runResult.ModelBuildOptions, + runResult.StatusLabelOptions, logger, p.existingDebugService(), runResult.TriggerMessageID, diff --git a/coderd/x/chatd/chatopenai/options.go b/coderd/x/chatd/chatopenai/options.go index 5623f76828748..d8851ce187b57 100644 --- a/coderd/x/chatd/chatopenai/options.go +++ b/coderd/x/chatd/chatopenai/options.go @@ -17,8 +17,9 @@ import ( func ProviderOptionsFromChatConfig( model fantasy.LanguageModel, options *codersdk.ChatModelOpenAIProviderOptions, + openAIResponsesOverride *bool, ) fantasy.ProviderOptionsData { - if UsesResponsesOptions(model) { + if UsesResponsesOptions(model, openAIResponsesOverride) { include := EnsureResponseIncludes(IncludeFromChat(options.Include)) providerOptions := &fantasyopenai.ResponsesProviderOptions{ Include: include, @@ -116,40 +117,53 @@ func EnsureResponseIncludes( return append(values, required) } +// UsesResponsesAPI reports whether a model uses the OpenAI Responses API. +// Callers must pass the same override the client was built with. +func UsesResponsesAPI(provider, modelID string, override *bool) bool { + switch provider { + case fantasyopenai.Name: + if override != nil { + return *override + } + return fantasyopenai.IsResponsesModel(modelID) + case fantasyazure.Name: + return fantasyopenai.IsResponsesModel(modelID) + default: + return false + } +} + // UsesResponsesOptions reports whether the model should use OpenAI Responses // API provider options. -func UsesResponsesOptions(model fantasy.LanguageModel) bool { +func UsesResponsesOptions(model fantasy.LanguageModel, override *bool) bool { if model == nil { return false } - switch model.Provider() { - case fantasyopenai.Name, fantasyazure.Name: - return fantasyopenai.IsResponsesModel(model.Model()) - default: - return false - } + return UsesResponsesAPI(model.Provider(), model.Model(), override) } -// ServiceTierFromChat normalizes chat-config service tier values for OpenAI -// Responses API and returns the canonical provider service tier value. +// ServiceTierFromChat normalizes chat-config service tier values for the +// OpenAI Responses API. It maps every tier the codersdk enum advertises, not +// only the ones fantasy declares constants for, because fantasy forwards the +// value to the API unchanged. func ServiceTierFromChat(value *string) *fantasyopenai.ServiceTier { normalized := chatutil.NormalizedStringPointer(value) if normalized == nil { return nil } - switch strings.ToLower(*normalized) { - case string(fantasyopenai.ServiceTierAuto): - serviceTier := fantasyopenai.ServiceTierAuto - return &serviceTier - case string(fantasyopenai.ServiceTierFlex): - serviceTier := fantasyopenai.ServiceTierFlex - return &serviceTier - case string(fantasyopenai.ServiceTierPriority): - serviceTier := fantasyopenai.ServiceTierPriority - return &serviceTier - default: + tier := chatutil.NormalizedEnumValue( + strings.ToLower(*normalized), + string(fantasyopenai.ServiceTierAuto), + "default", + string(fantasyopenai.ServiceTierFlex), + "scale", + string(fantasyopenai.ServiceTierPriority), + ) + if tier == nil { return nil } + serviceTier := fantasyopenai.ServiceTier(*tier) + return &serviceTier } // ResponsesLogProbsFromChatConfig maps chat-config log probability options to the diff --git a/coderd/x/chatd/chatopenai/options_test.go b/coderd/x/chatd/chatopenai/options_test.go index 32e8a8720454c..6d44da4b19cdb 100644 --- a/coderd/x/chatd/chatopenai/options_test.go +++ b/coderd/x/chatd/chatopenai/options_test.go @@ -46,6 +46,7 @@ func TestProviderOptionsFromChatConfigLegacy(t *testing.T) { got := chatopenai.ProviderOptionsFromChatConfig( fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-3.5-turbo-instruct"}, options, + nil, ) providerOptions, ok := got.(*fantasyopenai.ProviderOptions) @@ -98,6 +99,7 @@ func TestProviderOptionsFromChatConfigResponses(t *testing.T) { got := chatopenai.ProviderOptionsFromChatConfig( fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"}, options, + nil, ) providerOptions, ok := got.(*fantasyopenai.ResponsesProviderOptions) @@ -243,10 +245,14 @@ func TestEnsureResponseIncludes(t *testing.T) { func TestUsesResponsesOptions(t *testing.T) { t.Parallel() + forceResponses := true + forceCompletions := false + tests := []struct { - name string - model fantasy.LanguageModel - want bool + name string + model fantasy.LanguageModel + override *bool + want bool }{ {name: "Nil"}, { @@ -267,13 +273,39 @@ func TestUsesResponsesOptions(t *testing.T) { name: "NonOpenAIProvider", model: fakeLanguageModel{provider: "other", model: "gpt-4.1"}, }, + { + name: "NilModelIgnoresOverride", + override: &forceResponses, + }, + { + name: "OverrideForcesResponsesForUnknownModel", + model: fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-9-brand-new"}, + override: &forceResponses, + want: true, + }, + { + name: "OverrideForcesCompletionsForResponsesModel", + model: fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"}, + override: &forceCompletions, + }, + { + name: "AzureIgnoresOverride", + model: fakeLanguageModel{provider: fantasyazure.Name, model: "gpt-4.1"}, + override: &forceCompletions, + want: true, + }, + { + name: "NonOpenAIProviderIgnoresOverride", + model: fakeLanguageModel{provider: "other", model: "gpt-4.1"}, + override: &forceResponses, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := chatopenai.UsesResponsesOptions(tt.model) + got := chatopenai.UsesResponsesOptions(tt.model, tt.override) require.Equal(t, tt.want, got) }) } @@ -292,7 +324,8 @@ func TestServiceTierFromChat(t *testing.T) { {name: "Auto", value: ptr(" auto "), want: ptr(fantasyopenai.ServiceTierAuto)}, {name: "FlexCase", value: ptr(" FLEX "), want: ptr(fantasyopenai.ServiceTierFlex)}, {name: "Priority", value: ptr("priority"), want: ptr(fantasyopenai.ServiceTierPriority)}, - {name: "DefaultUnsupported", value: ptr("default")}, + {name: "Default", value: ptr("default"), want: ptr(fantasyopenai.ServiceTier("default"))}, + {name: "ScaleCase", value: ptr(" Scale "), want: ptr(fantasyopenai.ServiceTier("scale"))}, {name: "Invalid", value: ptr("fast")}, } diff --git a/coderd/x/chatd/chatopenai/transport_parity_test.go b/coderd/x/chatd/chatopenai/transport_parity_test.go new file mode 100644 index 0000000000000..ac1b24bfd1fd9 --- /dev/null +++ b/coderd/x/chatd/chatopenai/transport_parity_test.go @@ -0,0 +1,113 @@ +package chatopenai_test + +import ( + "reflect" + "strings" + "testing" + + fantasyopenai "charm.land/fantasy/providers/openai" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/x/chatd/chatopenai" + "github.com/coder/coder/v2/codersdk" +) + +// Keep this map in sync with the OpenAI transport selection table in +// ARCHITECTURE.md so intentional transport asymmetries remain explicit. +var openAIOptionTransportSupport = map[string]struct { + responses bool + chatCompletions bool +}{ + "include": {responses: true}, + "instructions": {responses: true}, + "logit_bias": {chatCompletions: true}, + "log_probs": {responses: true, chatCompletions: true}, + "top_log_probs": {responses: true, chatCompletions: true}, + "max_tool_calls": {responses: true}, + "parallel_tool_calls": {responses: true, chatCompletions: true}, + "user": {responses: true, chatCompletions: true}, + "reasoning_summary": {responses: true}, + "max_completion_tokens": {chatCompletions: true}, + "text_verbosity": {responses: true, chatCompletions: true}, + "prediction": {chatCompletions: true}, + "store": {responses: true, chatCompletions: true}, + "metadata": {responses: true, chatCompletions: true}, + "prompt_cache_key": {responses: true, chatCompletions: true}, + "safety_identifier": {responses: true, chatCompletions: true}, + "service_tier": {responses: true, chatCompletions: true}, + "structured_outputs": {chatCompletions: true}, + "strict_json_schema": {responses: true}, + // Web search fields configure tool wiring, not per-request provider + // options. + "web_search_enabled": {}, + "search_context_size": {}, + "allowed_domains": {}, +} + +// service_tier uses "default" to cover a codersdk tier without a named fantasy +// constant. +var sampledOpenAIOptions = codersdk.ChatModelOpenAIProviderOptions{ + Include: []string{string(fantasyopenai.IncludeFileSearchCallResults)}, + Instructions: ptr("instructions"), + LogitBias: map[string]int64{"50256": -10}, + LogProbs: ptr(true), + TopLogProbs: ptr(int64(3)), + MaxToolCalls: ptr(int64(8)), + ParallelToolCalls: ptr(true), + User: ptr("user-1"), + ReasoningSummary: ptr("auto"), + MaxCompletionTokens: ptr(int64(4096)), + TextVerbosity: ptr("high"), + Prediction: map[string]any{"type": "content"}, + Store: ptr(false), + Metadata: map[string]any{"scope": "unit"}, + PromptCacheKey: ptr("cache-key"), + SafetyIdentifier: ptr("safety-id"), + ServiceTier: ptr("default"), + StructuredOutputs: ptr(true), + StrictJSONSchema: ptr(true), + WebSearchEnabled: ptr(true), + SearchContextSize: ptr("low"), + AllowedDomains: []string{"example.com"}, +} + +func TestProviderOptionsTransportParity(t *testing.T) { + t.Parallel() + + optionsType := reflect.TypeOf(codersdk.ChatModelOpenAIProviderOptions{}) + require.Len(t, openAIOptionTransportSupport, optionsType.NumField()) + + for i := 0; i < optionsType.NumField(); i++ { + field := optionsType.Field(i) + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + want, ok := openAIOptionTransportSupport[name] + require.Truef(t, ok, "field %s is missing from openAIOptionTransportSupport", name) + + sample := reflect.ValueOf(sampledOpenAIOptions).Field(i) + require.Falsef(t, sample.IsZero(), "field %s needs a sample value in sampledOpenAIOptions", name) + + options := &codersdk.ChatModelOpenAIProviderOptions{} + reflect.ValueOf(options).Elem().Field(i).Set(sample) + + require.Equalf(t, want.responses, + optionChangesConvertedOutput(t, ptr(true), options), + "field %s on the Responses transport", name) + require.Equalf(t, want.chatCompletions, + optionChangesConvertedOutput(t, ptr(false), options), + "field %s on the Chat Completions transport", name) + } +} + +func optionChangesConvertedOutput( + t *testing.T, + responsesOverride *bool, + options *codersdk.ChatModelOpenAIProviderOptions, +) bool { + t.Helper() + model := fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"} + baseline := chatopenai.ProviderOptionsFromChatConfig( + model, &codersdk.ChatModelOpenAIProviderOptions{}, responsesOverride, + ) + converted := chatopenai.ProviderOptionsFromChatConfig(model, options, responsesOverride) + return !reflect.DeepEqual(baseline, converted) +} diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index ad450d6a5429c..4929a1580ce02 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -122,12 +122,10 @@ func InlineImageCapBytes(provider string) (int, bool) { } } -// AcceptsFilePartMediaType reports whether provider accepts mediaType -// as a file content part rather than silently dropping it. modelID -// distinguishes API paths within a provider (e.g. OpenAI Responses vs -// Chat Completions). Unknown providers return false so callers convert -// text-family content to text and guarantee the model still sees it. -func AcceptsFilePartMediaType(provider, modelID, mediaType string) bool { +// AcceptsFilePartMediaType reports whether a provider transport accepts +// mediaType as a native file part. Unknown providers return false so callers +// can avoid silently dropping unsupported text-family content. +func AcceptsFilePartMediaType(provider, modelID, mediaType string, openAIResponsesOverride *bool) bool { baseType := mediaType if parsed, _, err := mime.ParseMediaType(mediaType); err == nil { baseType = parsed @@ -140,7 +138,8 @@ func AcceptsFilePartMediaType(provider, modelID, mediaType string) bool { isAudio := baseType == "audio/wav" || baseType == "audio/mpeg" || baseType == "audio/mp3" isPDF := baseType == "application/pdf" - switch NormalizeProvider(provider) { + normalized := NormalizeProvider(provider) + switch normalized { case fantasygoogle.Name: // Google passes any file part through unfiltered. return true @@ -149,11 +148,8 @@ func AcceptsFilePartMediaType(provider, modelID, mediaType string) bool { // file-part acceptance, including text/* as native documents. return isImage || isText || isPDF case fantasyopenai.Name, fantasyazure.Name: - // chatd configures both with WithUseResponsesAPI, but only - // Responses-capable models actually use it. Non-Responses models - // fall through to the Chat Completions path, which accepts - // text/* and audio as native file parts (same as openaicompat). - if fantasyopenai.IsResponsesModel(modelID) { + // Chat Completions accepts text and audio as native file parts. + if chatopenai.UsesResponsesAPI(normalized, modelID, openAIResponsesOverride) { return isImage || isPDF } return isImage || isText || isAudio || isPDF @@ -898,12 +894,24 @@ func BetaHeadersFromCallConfig(providerName string, config *codersdk.ChatModelCa } } +// OpenAIResponsesAPIOverride returns the configured OpenAI Responses API +// override, or nil when the model config leaves the choice to the provider +// SDK's known-model list. +func OpenAIResponsesAPIOverride(config *codersdk.ChatModelOpenAIConfig) *bool { + if config == nil { + return nil + } + return config.UseResponsesAPI +} + // ModelFromConfig resolves a provider/model pair and constructs a fantasy // language model client using the provided provider credentials. The // userAgent is sent as the User-Agent header on every outgoing LLM // API request. extraHeaders, when non-nil, are sent as additional // HTTP headers on every request. httpClient, when non-nil, is used for -// all provider HTTP requests. +// all provider HTTP requests. openAIResponsesOverride, when non-nil, +// forces the OpenAI client onto the Responses API or Chat Completions +// instead of deciding from the provider SDK's known-model list. func ModelFromConfig( providerHint string, modelName string, @@ -911,6 +919,7 @@ func ModelFromConfig( userAgent string, extraHeaders map[string]string, httpClient *http.Client, + openAIResponsesOverride *bool, ) (fantasy.LanguageModel, error) { provider, modelID, err := ResolveModelWithProviderHint(modelName, providerHint) if err != nil { @@ -999,6 +1008,12 @@ func ModelFromConfig( fantasyopenai.WithUseResponsesAPI(), fantasyopenai.WithUserAgent(userAgent), } + if openAIResponsesOverride != nil { + forced := *openAIResponsesOverride + options = append(options, fantasyopenai.WithResponsesAPIFunc(func(string) bool { + return forced + })) + } if len(extraHeaders) > 0 { options = append(options, fantasyopenai.WithHeaders(extraHeaders)) } @@ -1099,6 +1114,7 @@ func missingProviderAPIKeyError(provider string) error { func ProviderOptionsFromChatModelConfig( model fantasy.LanguageModel, options *codersdk.ChatModelProviderOptions, + openAIResponsesOverride *bool, ) fantasy.ProviderOptions { if options == nil { return nil @@ -1110,6 +1126,7 @@ func ProviderOptionsFromChatModelConfig( result[fantasyopenai.Name] = chatopenai.ProviderOptionsFromChatConfig( model, options.OpenAI, + openAIResponsesOverride, ) } if options.Anthropic != nil { diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index b1b2a37a885f2..08433b2636900 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -394,7 +394,7 @@ func TestProviderOptionsFromChatModelConfig_AnthropicThinkingDisplay(t *testing. Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ ThinkingDisplay: ptr.Ref(" SUMMARIZED "), }, - }) + }, nil) require.NotNil(t, providerOptions) anthropicOptions, ok := providerOptions[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) @@ -915,6 +915,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) { chatprovider.UserAgent(), nil, nil, + nil, ) require.NoError(t, err) require.NotNil(t, model) @@ -931,6 +932,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) { chatprovider.UserAgent(), nil, nil, + nil, ) require.Nil(t, model) require.EqualError(t, err, "API key for provider \"bedrock\" is not set") @@ -973,6 +975,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) { chatprovider.UserAgent(), nil, nil, + nil, ) require.NoError(t, err) require.NotNil(t, model) @@ -1029,6 +1032,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) { chatprovider.UserAgent(), nil, nil, + nil, ) require.Nil(t, model) require.EqualError(t, err, tt.wantErr) @@ -1096,6 +1100,7 @@ func TestModelFromConfig_BedrockStripsAnthropicHeaders(t *testing.T) { chatprovider.UserAgent(), nil, nil, + nil, ) require.NoError(t, err) require.NotNil(t, model) @@ -1180,6 +1185,7 @@ func TestModelFromConfig_BedrockStreamingHeaders(t *testing.T) { chatprovider.UserAgent(), nil, nil, + nil, ) require.NoError(t, err) require.NotNil(t, model) @@ -1329,7 +1335,7 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) { BaseURLByProvider: map[string]string{"openai": serverURL}, } - model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers, nil) + model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers, nil, nil) require.NoError(t, err) _, err = model.Generate(ctx, fantasy.Call{ @@ -1360,7 +1366,7 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) { BaseURLByProvider: map[string]string{"anthropic": serverURL}, } - model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers, nil) + model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers, nil, nil) require.NoError(t, err) _, err = model.Generate(ctx, fantasy.Call{ @@ -1446,7 +1452,7 @@ func TestModelFromConfig_AnthropicBetaExtraHeader(t *testing.T) { betaHeaders := map[string]string{ chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M, } - model, err := chatprovider.ModelFromConfig(fantasyanthropic.Name, "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), betaHeaders, nil) + model, err := chatprovider.ModelFromConfig(fantasyanthropic.Name, "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), betaHeaders, nil, nil) require.NoError(t, err) require.NoError(t, generateHello(ctx, model)) @@ -1498,6 +1504,7 @@ func TestModelFromConfig_BedrockBetaExtraHeader(t *testing.T) { chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M, }, nil, + nil, ) require.NoError(t, err) @@ -1574,7 +1581,7 @@ func TestModelFromConfig_AnthropicPDFFilePartReachesProvider(t *testing.T) { BaseURLByProvider: map[string]string{"anthropic": serverURL}, } - model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), nil, nil) + model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), nil, nil, nil) require.NoError(t, err) _, err = model.Generate(ctx, fantasy.Call{ @@ -1615,7 +1622,7 @@ func TestModelFromConfig_NilExtraHeaders(t *testing.T) { BaseURLByProvider: map[string]string{"openai": serverURL}, } - model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil, nil) + model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil, nil, nil) require.NoError(t, err) _, err = model.Generate(ctx, fantasy.Call{ @@ -1659,6 +1666,7 @@ func TestModelFromConfig_HTTPClient(t *testing.T) { chatprovider.UserAgent(), nil, client, + nil, ) require.NoError(t, err) diff --git a/coderd/x/chatd/chatprovider/file_parts_test.go b/coderd/x/chatd/chatprovider/file_parts_test.go index 62d20e757e24c..09f90331f7579 100644 --- a/coderd/x/chatd/chatprovider/file_parts_test.go +++ b/coderd/x/chatd/chatprovider/file_parts_test.go @@ -14,77 +14,94 @@ func TestAcceptsFilePartMediaType(t *testing.T) { const responsesModel = "gpt-4o" const nonResponsesModel = "babbage-002" + forceResponses := true + forceCompletions := false + cases := []struct { name string provider string modelID string mediaType string want bool + override *bool }{ // OpenAI Responses accepts only images and PDFs. - {"openai-json", "openai", responsesModel, "application/json", false}, - {"openai-text", "openai", responsesModel, "text/plain", false}, - {"openai-image", "openai", responsesModel, "image/png", true}, - {"openai-pdf", "openai", responsesModel, "application/pdf", true}, + {"openai-json", "openai", responsesModel, "application/json", false, nil}, + {"openai-text", "openai", responsesModel, "text/plain", false, nil}, + {"openai-image", "openai", responsesModel, "image/png", true, nil}, + {"openai-pdf", "openai", responsesModel, "application/pdf", true, nil}, // OpenAI Chat Completions (non-Responses models) accepts text/* // and audio as native file parts. - {"openai-non-responses-text", "openai", nonResponsesModel, "text/plain", true}, - {"openai-non-responses-json", "openai", nonResponsesModel, "application/json", false}, - {"openai-non-responses-image", "openai", nonResponsesModel, "image/png", true}, + {"openai-non-responses-text", "openai", nonResponsesModel, "text/plain", true, nil}, + {"openai-non-responses-json", "openai", nonResponsesModel, "application/json", false, nil}, + {"openai-non-responses-image", "openai", nonResponsesModel, "image/png", true, nil}, // Azure uses the Responses API, same as OpenAI. - {"azure-text", "azure", responsesModel, "text/markdown", false}, - {"azure-image", "azure", responsesModel, "image/jpeg", true}, + {"azure-text", "azure", responsesModel, "text/markdown", false, nil}, + {"azure-image", "azure", responsesModel, "image/jpeg", true, nil}, // Anthropic accepts text/* as native documents, but not JSON. - {"anthropic-text", "anthropic", "", "text/markdown", true}, - {"anthropic-json", "anthropic", "", "application/json", false}, - {"anthropic-pdf", "anthropic", "", "application/pdf", true}, - {"anthropic-image", "anthropic", "", "image/webp", true}, + {"anthropic-text", "anthropic", "", "text/markdown", true, nil}, + {"anthropic-json", "anthropic", "", "application/json", false, nil}, + {"anthropic-pdf", "anthropic", "", "application/pdf", true, nil}, + {"anthropic-image", "anthropic", "", "image/webp", true, nil}, // Bedrock wraps Anthropic, so it matches Anthropic. - {"bedrock-text", "bedrock", "", "text/csv", true}, - {"bedrock-json", "bedrock", "", "application/json", false}, + {"bedrock-text", "bedrock", "", "text/csv", true, nil}, + {"bedrock-json", "bedrock", "", "application/json", false, nil}, // OpenAI-compatible accepts text/*, images, audio, and PDFs. - {"openaicompat-text", "openai-compat", "", "text/plain", true}, - {"openaicompat-json", "openai-compat", "", "application/json", false}, - {"openaicompat-audio", "openai-compat", "", "audio/mpeg", true}, + {"openaicompat-text", "openai-compat", "", "text/plain", true, nil}, + {"openaicompat-json", "openai-compat", "", "application/json", false, nil}, + {"openaicompat-audio", "openai-compat", "", "audio/mpeg", true, nil}, // OpenRouter and Vercel do not accept text file parts. - {"openrouter-text", "openrouter", "", "text/plain", false}, - {"openrouter-json", "openrouter", "", "application/json", false}, - {"openrouter-image", "openrouter", "", "image/png", true}, - {"vercel-text", "vercel", "", "text/plain", false}, - {"vercel-json", "vercel", "", "application/json", false}, - {"vercel-pdf", "vercel", "", "application/pdf", true}, + {"openrouter-text", "openrouter", "", "text/plain", false, nil}, + {"openrouter-json", "openrouter", "", "application/json", false, nil}, + {"openrouter-image", "openrouter", "", "image/png", true, nil}, + {"vercel-text", "vercel", "", "text/plain", false, nil}, + {"vercel-json", "vercel", "", "application/json", false, nil}, + {"vercel-pdf", "vercel", "", "application/pdf", true, nil}, // Google passes all file parts through unfiltered. - {"google-json", "google", "", "application/json", true}, - {"google-text", "google", "", "text/plain", true}, - {"google-anything", "google", "", "application/octet-stream", true}, + {"google-json", "google", "", "application/json", true, nil}, + {"google-text", "google", "", "text/plain", true, nil}, + {"google-anything", "google", "", "application/octet-stream", true, nil}, // Unknown providers reject everything so text-family content is // converted to text and still reaches the model. - {"unknown-text", "made-up-provider", "", "text/plain", false}, - {"empty-text", "", "", "text/plain", false}, + {"unknown-text", "made-up-provider", "", "text/plain", false, nil}, + {"empty-text", "", "", "text/plain", false, nil}, // Base media type handling: parameters are stripped. - {"anthropic-text-charset", "anthropic", "", "text/plain; charset=utf-8", true}, - {"openai-text-charset", "openai", responsesModel, "text/plain; charset=utf-8", false}, + {"anthropic-text-charset", "anthropic", "", "text/plain; charset=utf-8", true, nil}, + {"openai-text-charset", "openai", responsesModel, "text/plain; charset=utf-8", false, nil}, // Provider name normalization is case-insensitive. - {"anthropic-uppercase", "Anthropic", "", "text/plain", true}, + {"anthropic-uppercase", "Anthropic", "", "text/plain", true, nil}, + + {"openai-forced-responses-text", "openai", nonResponsesModel, "text/plain", false, &forceResponses}, + {"openai-forced-responses-image", "openai", nonResponsesModel, "image/png", true, &forceResponses}, + {"openai-forced-completions-text", "openai", responsesModel, "text/plain", true, &forceCompletions}, + {"openai-forced-completions-audio", "openai", responsesModel, "audio/mpeg", true, &forceCompletions}, + + // Azure has no equivalent provider option, so it keeps using the + // known-model list even when a config sets the override. + {"azure-ignores-forced-completions", "azure", responsesModel, "text/plain", false, &forceCompletions}, + {"azure-ignores-forced-responses", "azure", nonResponsesModel, "text/plain", true, &forceResponses}, + + {"anthropic-ignores-override", "anthropic", "", "text/plain", true, &forceResponses}, + {"openaicompat-ignores-override", "openai-compat", "", "text/plain", true, &forceResponses}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := chatprovider.AcceptsFilePartMediaType(tc.provider, tc.modelID, tc.mediaType) + got := chatprovider.AcceptsFilePartMediaType(tc.provider, tc.modelID, tc.mediaType, tc.override) if got != tc.want { - t.Fatalf("AcceptsFilePartMediaType(%q, %q, %q) = %v, want %v", - tc.provider, tc.modelID, tc.mediaType, got, tc.want) + t.Fatalf("AcceptsFilePartMediaType(%q, %q, %q, %v) = %v, want %v", + tc.provider, tc.modelID, tc.mediaType, tc.override, got, tc.want) } }) } diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go index abb97079c6761..4b864c2924141 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go @@ -67,6 +67,7 @@ func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string) m chatprovider.UserAgent(), nil, &http.Client{Transport: transport}, + nil, ) require.NoError(t, err) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 95ee8fe24ef7c..31138cca4b20f 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -92,6 +92,7 @@ func ApplyReasoningEffort( model fantasy.LanguageModel, options fantasy.ProviderOptions, effort *string, + openAIResponsesOverride *bool, ) fantasy.ProviderOptions { if effort == nil || model == nil { return options @@ -109,7 +110,7 @@ func ApplyReasoningEffort( case *fantasyopenai.ProviderOptions: opts.ReasoningEffort = &providerEffort default: - if chatopenai.UsesResponsesOptions(model) { + if chatopenai.UsesResponsesOptions(model, openAIResponsesOverride) { options[fantasyopenai.Name] = &fantasyopenai.ResponsesProviderOptions{ ReasoningEffort: &providerEffort, } diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_test.go index d301c6d589312..a7c270bbc1085 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_test.go @@ -85,7 +85,7 @@ func TestApplyReasoningEffort(t *testing.T) { t.Run("CreatesOpenAIResponsesEntry", func(t *testing.T) { t.Parallel() - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil, new(codersdk.ChatModelReasoningEffortHigh)) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil, new(codersdk.ChatModelReasoningEffortHigh), nil) providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) require.True(t, ok, "%T", got[fantasyopenai.Name]) require.NotNil(t, providerOptions.ReasoningEffort) @@ -101,7 +101,7 @@ func TestApplyReasoningEffort(t *testing.T) { Store: ptr.Ref(true), }, } - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, options, new(codersdk.ChatModelReasoningEffortHigh)) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, options, new(codersdk.ChatModelReasoningEffortHigh), nil) providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) require.True(t, ok, "%T", got[fantasyopenai.Name]) require.Same(t, options[fantasyopenai.Name], providerOptions) @@ -119,7 +119,7 @@ func TestApplyReasoningEffort(t *testing.T) { ParallelToolCalls: ptr.Ref(true), }, } - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, options, new(codersdk.ChatModelReasoningEffortHigh)) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, options, new(codersdk.ChatModelReasoningEffortHigh), nil) providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions) require.True(t, ok, "%T", got[fantasyopenai.Name]) require.Same(t, options[fantasyopenai.Name], providerOptions) @@ -225,7 +225,7 @@ func TestApplyReasoningEffort(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: tt.provider}, tt.options, new(codersdk.ChatModelReasoningEffortHigh)) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: tt.provider}, tt.options, new(codersdk.ChatModelReasoningEffortHigh), nil) tt.assert(t, got) }) } diff --git a/coderd/x/chatd/chatprovider/responses_api_test.go b/coderd/x/chatd/chatprovider/responses_api_test.go new file mode 100644 index 0000000000000..30572568fc089 --- /dev/null +++ b/coderd/x/chatd/chatprovider/responses_api_test.go @@ -0,0 +1,174 @@ +package chatprovider_test + +import ( + "context" + "sync" + "testing" + + "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" +) + +func TestModelFromConfig_OpenAIResponsesAPIOverride(t *testing.T) { + t.Parallel() + + // Taken from opposite sides of the provider SDK's known-model list. + const responsesModel = "gpt-4o" + const nonResponsesModel = "babbage-002" + + forceResponses := true + forceCompletions := false + + cases := []struct { + name string + model string + override *bool + wantPath string + }{ + {"DefaultKnownModel", responsesModel, nil, "/responses"}, + {"DefaultUnknownModel", nonResponsesModel, nil, "/chat/completions"}, + {"ForceResponsesOnUnknownModel", nonResponsesModel, &forceResponses, "/responses"}, + {"ForceCompletionsOnKnownModel", responsesModel, &forceCompletions, "/chat/completions"}, + {"ForceResponsesOnKnownModel", responsesModel, &forceResponses, "/responses"}, + {"ForceCompletionsOnUnknownModel", nonResponsesModel, &forceCompletions, "/chat/completions"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var gotPath string + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + mu.Lock() + gotPath = req.Request.URL.Path + mu.Unlock() + return chattest.OpenAINonStreamingResponse("ok") + }) + + model, err := chatprovider.ModelFromConfig( + fantasyopenai.Name, + tc.model, + chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{fantasyopenai.Name: "test-key"}, + BaseURLByProvider: map[string]string{fantasyopenai.Name: serverURL}, + }, + chatprovider.UserAgent(), + nil, + nil, + tc.override, + ) + require.NoError(t, err) + + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: []fantasy.Message{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "Test message"}}, + }}, + }) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, tc.wantPath, gotPath) + }) + } +} + +func TestOpenAIResponsesAPIOverride(t *testing.T) { + t.Parallel() + + useResponsesAPI := true + + t.Run("NilConfig", func(t *testing.T) { + t.Parallel() + require.Nil(t, chatprovider.OpenAIResponsesAPIOverride(nil)) + }) + + t.Run("Unset", func(t *testing.T) { + t.Parallel() + require.Nil(t, chatprovider.OpenAIResponsesAPIOverride(&codersdk.ChatModelOpenAIConfig{})) + }) + + t.Run("Set", func(t *testing.T) { + t.Parallel() + got := chatprovider.OpenAIResponsesAPIOverride(&codersdk.ChatModelOpenAIConfig{ + UseResponsesAPI: &useResponsesAPI, + }) + require.NotNil(t, got) + require.True(t, *got) + }) +} + +// When other OpenAI options exist, the override must select the struct type +// the chosen API reads. +func TestProviderOptionsFromChatModelConfig_ResponsesAPIOverride(t *testing.T) { + t.Parallel() + + const responsesModel = "gpt-4o" + const nonResponsesModel = "babbage-002" + + forceResponses := true + forceCompletions := false + serviceTier := "auto" + + t.Run("ForceResponsesUsesResponsesOptions", func(t *testing.T) { + t.Parallel() + got := chatprovider.ProviderOptionsFromChatModelConfig( + &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: nonResponsesModel}, + &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier}, + }, + &forceResponses, + ) + require.IsType(t, &fantasyopenai.ResponsesProviderOptions{}, got[fantasyopenai.Name]) + }) + + t.Run("ForceCompletionsUsesCompletionsOptions", func(t *testing.T) { + t.Parallel() + got := chatprovider.ProviderOptionsFromChatModelConfig( + &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: responsesModel}, + &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier}, + }, + &forceCompletions, + ) + require.IsType(t, &fantasyopenai.ProviderOptions{}, got[fantasyopenai.Name]) + }) +} + +// When the override is the only OpenAI option, ApplyReasoningEffort creates +// the provider options itself and must match the chosen API. +func TestApplyReasoningEffort_ResponsesAPIOverride(t *testing.T) { + t.Parallel() + + forceResponses := true + forceCompletions := false + + t.Run("ForceResponsesOnUnknownModel", func(t *testing.T) { + t.Parallel() + got := chatprovider.ApplyReasoningEffort( + &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "babbage-002"}, + nil, + new(codersdk.ChatModelReasoningEffortHigh), + &forceResponses, + ) + require.IsType(t, &fantasyopenai.ResponsesProviderOptions{}, got[fantasyopenai.Name]) + }) + + t.Run("ForceCompletionsOnKnownModel", func(t *testing.T) { + t.Parallel() + got := chatprovider.ApplyReasoningEffort( + &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4o"}, + nil, + new(codersdk.ChatModelReasoningEffortHigh), + &forceCompletions, + ) + require.IsType(t, &fantasyopenai.ProviderOptions{}, got[fantasyopenai.Name]) + }) +} diff --git a/coderd/x/chatd/chatprovider/useragent_test.go b/coderd/x/chatd/chatprovider/useragent_test.go index 7b4ba9319a783..fbe48f68f7f0b 100644 --- a/coderd/x/chatd/chatprovider/useragent_test.go +++ b/coderd/x/chatd/chatprovider/useragent_test.go @@ -48,7 +48,7 @@ func TestModelFromConfig_UserAgent(t *testing.T) { BaseURLByProvider: map[string]string{"openai": serverURL}, } - model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, expectedUA, nil, nil) + model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, expectedUA, nil, nil, nil) require.NoError(t, err) // Make a real call so Fantasy sends an HTTP request to the diff --git a/coderd/x/chatd/compaction_override.go b/coderd/x/chatd/compaction_override.go index 0e3d4d48604bc..f784f074f61bb 100644 --- a/coderd/x/chatd/compaction_override.go +++ b/coderd/x/chatd/compaction_override.go @@ -42,6 +42,9 @@ type compactionModelOverride struct { // providerOptions include the override's reasoning effort for the // summary call. providerOptions fantasy.ProviderOptions + // openAIResponsesOverride keeps prompt sanitization aligned with the + // API the override model client was built for. + openAIResponsesOverride *bool } // resolvedCompactionOverride is the compaction override resolved at @@ -136,10 +139,11 @@ func (p *Server) buildCompactionOverrideModel( ) } model, _, err := p.newDebugAwareModel(ctx, modelClientRequest{ - Chat: chat, - ModelName: modelConfig.Model, - UserAgent: chatprovider.UserAgent(), - ExtraHeaders: chatprovider.CoderHeaders(chat), + Chat: chat, + ModelName: modelConfig.Model, + UserAgent: chatprovider.UserAgent(), + ExtraHeaders: chatprovider.CoderHeaders(chat), + ConfigOptions: modelConfig.Options, }, route, modelOpts) if err != nil { return compactionModelOverride{}, xerrors.Errorf( @@ -147,16 +151,17 @@ func (p *Server) buildCompactionOverrideModel( err, ) } - providerOptions, err := compactionOverrideProviderOptions(model, modelConfig) + providerOptions, responsesOverride, err := compactionOverrideProviderOptions(model, modelConfig) if err != nil { return compactionModelOverride{}, err } return compactionModelOverride{ - modelConfig: modelConfig, - model: model, - resolvedProvider: resolvedProvider, - resolvedModel: resolvedModel, - providerOptions: providerOptions, + modelConfig: modelConfig, + model: model, + resolvedProvider: resolvedProvider, + resolvedModel: resolvedModel, + providerOptions: providerOptions, + openAIResponsesOverride: responsesOverride, }, nil } @@ -166,23 +171,30 @@ func (p *Server) buildCompactionOverrideModel( func compactionOverrideProviderOptions( model fantasy.LanguageModel, modelConfig database.ChatModelConfig, -) (fantasy.ProviderOptions, error) { +) (fantasy.ProviderOptions, *bool, error) { callConfig := codersdk.ChatModelCallConfig{} if len(modelConfig.Options) > 0 { if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { - return nil, xerrors.Errorf( + return nil, nil, xerrors.Errorf( "parse compaction model override call config: %w", err, ) } } + responsesOverride := chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig) providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( model, callConfig.ProviderOptions, + responsesOverride, ) reasoningEffort := chatprovider.ResolveReasoningEffort( nil, callConfig.ReasoningEffort, ) - return chatprovider.ApplyReasoningEffort(model, providerOptions, reasoningEffort), nil + return chatprovider.ApplyReasoningEffort( + model, + providerOptions, + reasoningEffort, + responsesOverride, + ), responsesOverride, nil } diff --git a/coderd/x/chatd/compaction_override_internal_test.go b/coderd/x/chatd/compaction_override_internal_test.go index 9267c84446d79..d325b91a89927 100644 --- a/coderd/x/chatd/compaction_override_internal_test.go +++ b/coderd/x/chatd/compaction_override_internal_test.go @@ -25,7 +25,7 @@ func TestCompactionOverrideProviderOptions(t *testing.T) { t.Run("NoOptions", func(t *testing.T) { t.Parallel() - opts, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{}) + opts, _, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{}) require.NoError(t, err) require.Nil(t, opts) }) @@ -40,7 +40,7 @@ func TestCompactionOverrideProviderOptions(t *testing.T) { }, }) require.NoError(t, err) - opts, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: options}) + opts, _, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: options}) require.NoError(t, err) anthropicOpts, ok := opts[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) require.True(t, ok) @@ -50,7 +50,7 @@ func TestCompactionOverrideProviderOptions(t *testing.T) { t.Run("MalformedOptions", func(t *testing.T) { t.Parallel() - _, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: []byte("{")}) + _, _, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: []byte("{")}) require.ErrorContains(t, err, "parse compaction model override call config") }) } diff --git a/coderd/x/chatd/compaction_sanitize.go b/coderd/x/chatd/compaction_sanitize.go index 9f4676b29f55f..1f0b939cab61d 100644 --- a/coderd/x/chatd/compaction_sanitize.go +++ b/coderd/x/chatd/compaction_sanitize.go @@ -30,6 +30,7 @@ func sanitizeCompactionPrompt( compactionModel fantasy.LanguageModel, chatConfig database.ChatModelConfig, overrideConfig database.ChatModelConfig, + openAIResponsesOverride *bool, ) []fantasy.Message { messages := prompt if !sameCompactionProviderIdentity(chatConfig, overrideConfig) { @@ -40,6 +41,7 @@ func sanitizeCompactionPrompt( compactionModel.Provider(), compactionModel.Model(), mediaType, + openAIResponsesOverride, ) }) sanitized, stats := chatsanitize.SanitizeAnthropicProviderToolHistory( diff --git a/coderd/x/chatd/compaction_sanitize_internal_test.go b/coderd/x/chatd/compaction_sanitize_internal_test.go index f3b21f7bc10e2..7e18498baede1 100644 --- a/coderd/x/chatd/compaction_sanitize_internal_test.go +++ b/coderd/x/chatd/compaction_sanitize_internal_test.go @@ -71,7 +71,7 @@ func TestSanitizeCompactionPrompt_FlattensForeignProviderExecutedToolParts(t *te } compactionModel := &chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"} - sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New())) + sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()), nil) require.Len(t, sanitized, 3) // Provider-executed parts are flattened to text so the summary keeps @@ -120,7 +120,7 @@ func TestSanitizeCompactionPrompt_DropsNonAssistantProviderExecutedParts(t *test } compactionModel := &chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"} - sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New())) + sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()), nil) require.Len(t, sanitized, 1) require.Equal(t, fantasy.MessageRoleUser, sanitized[0].Role) @@ -150,7 +150,7 @@ func TestSanitizeCompactionPrompt_ReplacesUnsupportedFileParts(t *testing.T) { // placeholder while the prompt stays otherwise intact. compactionModel := &chattest.FakeModel{ProviderName: "mistral", ModelName: "mistral-large"} sharedProviderID := uuid.New() - sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID)) + sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID), nil) require.Len(t, sanitized, 1) require.Len(t, sanitized[0].Content, 2) @@ -190,7 +190,7 @@ func TestSanitizeCompactionPrompt_SameProviderKeepsProviderExecutedParts(t *test compactionModel := &chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"} sharedProviderID := uuid.New() - sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID)) + sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID), nil) require.Len(t, sanitized, 1) require.Len(t, sanitized[0].Content, 2) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 7e87da9a2360a..60048b2c6ddf3 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -937,6 +937,7 @@ func (s *taskStarter) generateCompaction( overrideModel.model, prepared.Compaction.ChatModelConfig, overrideModel.modelConfig, + overrideModel.openAIResponsesOverride, ) } preResult, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventPreCompact) diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index fcbfbf744d007..d3552e83cc9bb 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -101,6 +101,45 @@ func (server *Server) prepareGeneration( callConfig.MaxOutputTokens = &maxOutputTokens } + // Computer-use turns swap in a specialized model, so the substitution + // must happen before anything model-sensitive runs: file-part + // classification, history sanitization, and provider option preparation + // must all agree with the client actually used for the turn. + isComputerUse := chat.Mode.Valid && chat.Mode.ChatMode == database.ChatModeComputerUse + var computerUseProvider codersdk.ChatComputerUseProvider + if isComputerUse { + var cuModelProvider, cuModelName string + computerUseProvider, cuModelProvider, cuModelName, err = server.computerUseProviderAndModelFromConfig(ctx) + if err != nil { + return generationPrepared{}, xerrors.Errorf("resolve computer use provider and model: %w", err) + } + computerUseRoute, keyErr := server.resolveModelRouteForProviderType(ctx, chat.OwnerID, cuModelProvider) + if keyErr != nil { + return generationPrepared{}, xerrors.Errorf("resolve computer use provider route: %w", keyErr) + } + modelRoute = computerUseRoute + cuModel, cuDebugEnabled, cuResolvedProvider, cuResolvedModel, cuErr := server.resolveComputerUseModel( + ctx, + chat, + computerUseRoute, + computerUseProvider, + cuModelProvider, + cuModelName, + modelOpts, + ) + if cuErr != nil { + return generationPrepared{}, cuErr + } + model = cuModel + debugEnabled = cuDebugEnabled + resolvedProvider = cuResolvedProvider + debugModel = cuResolvedModel + // The computer-use client is built without ConfigOptions, so the + // chat model's transport override must not follow the substituted + // model into provider option preparation. + callConfig.OpenAIConfig = nil + } + currentPlanMode := chat.PlanMode isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan isExploreSubagent := isExploreSubagentMode(chat.Mode) @@ -259,7 +298,12 @@ func (server *Server) prepareGeneration( // Anthropic transport). The conversion that actually drops or // accepts a file part is the one for model.Provider(). acceptsFilePart := func(mediaType string) bool { - return chatprovider.AcceptsFilePartMediaType(model.Provider(), model.Model(), mediaType) + return chatprovider.AcceptsFilePartMediaType( + model.Provider(), + model.Model(), + mediaType, + chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig), + ) } providerType := string(modelRoute.Provider.Type) prompt, err = chatprompt.ConvertMessagesWithFiles(ctx, promptRows, server.chatFileResolver(providerType), logger, acceptsFilePart) @@ -480,36 +524,7 @@ func (server *Server) prepareGeneration( } } - isComputerUse := chat.Mode.Valid && chat.Mode.ChatMode == database.ChatModeComputerUse if isComputerUse { - computerUseProvider, computerUseModelProvider, computerUseModelName, err := server.computerUseProviderAndModelFromConfig(ctx) - if err != nil { - cleanup() - return generationPrepared{}, xerrors.Errorf("resolve computer use provider and model: %w", err) - } - computerUseRoute, keyErr := server.resolveModelRouteForProviderType(ctx, chat.OwnerID, computerUseModelProvider) - if keyErr != nil { - cleanup() - return generationPrepared{}, xerrors.Errorf("resolve computer use provider route: %w", keyErr) - } - modelRoute = computerUseRoute - cuModel, cuDebugEnabled, cuResolvedProvider, cuResolvedModel, cuErr := server.resolveComputerUseModel( - ctx, - chat, - computerUseRoute, - computerUseProvider, - computerUseModelProvider, - computerUseModelName, - modelOpts, - ) - if cuErr != nil { - cleanup() - return generationPrepared{}, cuErr - } - model = cuModel - debugEnabled = cuDebugEnabled - resolvedProvider = cuResolvedProvider - debugModel = cuResolvedModel providerTools, err = appendComputerUseProviderTool(providerTools, computerUseProviderToolOptions{ provider: computerUseProvider, isPlanModeTurn: isPlanModeTurn, @@ -542,8 +557,18 @@ func (server *Server) prepareGeneration( requestedEffort, callConfig.ReasoningEffort, ) - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions) - providerOptions = chatprovider.ApplyReasoningEffort(model, providerOptions, reasoningEffort) + responsesOverride := chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig) + providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( + model, + callConfig.ProviderOptions, + responsesOverride, + ) + providerOptions = chatprovider.ApplyReasoningEffort( + model, + providerOptions, + reasoningEffort, + responsesOverride, + ) activeToolNames := activeToolNamesForTurn(tools, currentPlanMode, chat.ParentChatID, approvedPlanMCPConfigIDs) if isExploreSubagent { @@ -768,7 +793,7 @@ func (server *Server) deriveFinalTurnRunResult( return runChatResult{FinalAssistantText: finalAssistantText, TriggerMessageID: triggerMessageID, HistoryTipMessageID: historyTipMessageID} } modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID} - model, _, modelRoute, _, resolvedProvider, resolvedModel, err := server.resolveChatModel(ctx, chat, modelOpts) + model, dbConfig, modelRoute, _, resolvedProvider, resolvedModel, err := server.resolveChatModel(ctx, chat, modelOpts) if err != nil { // Return what we have; generateFinalTurnStatusLabel falls back to a // generic label when StatusLabelModel is nil. @@ -787,6 +812,7 @@ func (server *Server) deriveFinalTurnRunResult( FallbackRoute: modelRoute, FallbackModel: resolvedModel, ModelBuildOptions: modelOpts, + StatusLabelOptions: dbConfig.Options, TriggerMessageID: triggerMessageID, HistoryTipMessageID: historyTipMessageID, } diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 0004da960bc8c..9f3059aea29da 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -2,8 +2,10 @@ package chatd //nolint:testpackage // Exercises unexported re-derivation helpers import ( "encoding/json" + "strings" "testing" + "charm.land/fantasy" fantasyopenai "charm.land/fantasy/providers/openai" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" @@ -158,6 +160,114 @@ func TestPrepareGenerationClampsRequestedReasoningEffortToMax(t *testing.T) { require.Equal(t, fantasyopenai.ReasoningEffortMedium, *providerOptions.ReasoningEffort) } +func TestPrepareGenerationComputerUseIgnoresChatTransportOverride(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := chatdTestContext(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + require.NoError(t, db.UpsertChatComputerUseProvider(ctx, string(codersdk.ChatComputerUseProviderOpenAI))) + provider := dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{ + Type: database.AIProviderTypeOpenai, + }, "test-key") + forceCompletions := false + modelConfigRaw, err := json.Marshal(codersdk.ChatModelCallConfig{ + OpenAIConfig: &codersdk.ChatModelOpenAIConfig{ + UseResponsesAPI: &forceCompletions, + }, + ProviderOptions: &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ + User: ptr.Ref("computer-use"), + }, + }, + }) + require.NoError(t, err) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "gpt-4o-mini", + Options: modelConfigRaw, + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + }, func(p *database.InsertChatModelConfigParams) { + p.Enabled = true + }) + + const attachmentText = "text attachment body" + file, err := db.InsertChatFile(ctx, database.InsertChatFileParams{ + OwnerID: user.ID, + OrganizationID: org.ID, + Name: "notes.txt", + Mimetype: "text/plain", + Data: []byte(attachmentText), + }) + require.NoError(t, err) + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("hello"), + codersdk.ChatMessageFile(file.ID, "text/plain", "notes.txt"), + }) + require.NoError(t, err) + + created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: modelConfig.ID, + Title: "computer use transport", + ClientType: database.ChatClientTypeApi, + Mode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true}, + InitialMessages: []chatstate.Message{ + { + Role: database.ChatMessageRoleUser, + Content: content, + Visibility: database.ChatMessageVisibilityBoth, + ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true}, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + ContentVersion: chatprompt.CurrentContentVersion, + }, + }, + }) + require.NoError(t, err) + + server := newInternalTestServer( + t, + db, + ps, + chatprovider.ProviderAPIKeys{}, + withInternalTestServerTransportFactory(&aibridgeTestFactory{}), + ) + prepared, err := server.prepareGeneration(ctx, generationPrepareInput{ + Chat: created.Chat, + Messages: created.InitialMessages, + }) + require.NoError(t, err) + t.Cleanup(prepared.Cleanup) + + // The computer-use model (gpt-5.5) is Responses-selected by the SDK and + // its client ignores the config's forced Chat Completions, so the + // options must be the Responses type or the SDK discards them. + _, ok := prepared.ProviderOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.True(t, ok, "%T", prepared.ProviderOptions[fantasyopenai.Name]) + + // File classification must also key on the substituted model: the + // Responses transport drops native text file parts, so the attachment + // must be inlined as text rather than kept as a FilePart. + var sawInlinedText bool + for _, message := range prepared.Prompt { + for _, part := range message.Content { + if filePart, isFile := part.(fantasy.FilePart); isFile { + t.Fatalf("text attachment survived as FilePart %q", filePart.Filename) + } + if textPart, isText := part.(fantasy.TextPart); isText && + strings.Contains(textPart.Text, attachmentText) { + sawInlinedText = true + } + } + } + require.True(t, sawInlinedText, "attachment was not inlined as text") +} + func TestPrepareGenerationSubagentUsesOwnerSyntheticAPIKey(t *testing.T) { t.Parallel() @@ -256,7 +366,7 @@ func TestDeriveFinalTurnRunResult(t *testing.T) { modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ Model: "gpt-4o-mini", DisplayName: "gpt-4o-mini", - Options: json.RawMessage(`{}`), + Options: json.RawMessage(`{"openai_config":{"use_responses_api":false}}`), }, func(p *database.InsertChatModelConfigParams) { p.Enabled = true p.IsDefault = true @@ -334,6 +444,7 @@ func TestDeriveFinalTurnRunResult(t *testing.T) { require.NotNil(t, result.StatusLabelModel) require.Equal(t, "openai", result.FallbackProvider) require.Equal(t, "gpt-4o-mini", result.FallbackModel) + require.JSONEq(t, `{"openai_config":{"use_responses_api":false}}`, string(result.StatusLabelOptions)) }) t.Run("NonWaitingReturnsEmpty", func(t *testing.T) { diff --git a/coderd/x/chatd/model_routing.go b/coderd/x/chatd/model_routing.go index f67beb17976b1..5a07ace623012 100644 --- a/coderd/x/chatd/model_routing.go +++ b/coderd/x/chatd/model_routing.go @@ -46,6 +46,7 @@ func newLanguageModel( userAgent string, extraHeaders map[string]string, httpClient *http.Client, + openAIResponsesOverride *bool, ) (fantasy.LanguageModel, error) { model, err := chatprovider.ModelFromConfig( providerHint, @@ -54,6 +55,7 @@ func newLanguageModel( userAgent, extraHeaders, httpClient, + openAIResponsesOverride, ) if err != nil { return nil, err diff --git a/coderd/x/chatd/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go index 2bd724869ae99..85b4476ec2f52 100644 --- a/coderd/x/chatd/model_routing_aibridge.go +++ b/coderd/x/chatd/model_routing_aibridge.go @@ -167,10 +167,11 @@ func (p *Server) newModel( } config := fantasyConfigForAIBridge(route.Provider.Type) - extraHeaders, err := mergeConfigBetaHeaders(req.ExtraHeaders, config.ProviderHint, req.ConfigOptions) + callConfig, err := parseModelConfigOptions(req.ConfigOptions) if err != nil { return nil, err } + extraHeaders := mergeConfigBetaHeaders(req.ExtraHeaders, config.ProviderHint, callConfig) return newLanguageModel( config.ProviderHint, req.ModelName, @@ -178,26 +179,31 @@ func (p *Server) newModel( req.UserAgent, extraHeaders, &http.Client{Transport: baseRT}, + chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig), ) } +func parseModelConfigOptions(configOptions json.RawMessage) (codersdk.ChatModelCallConfig, error) { + var callConfig codersdk.ChatModelCallConfig + if len(configOptions) == 0 { + return callConfig, nil + } + if err := json.Unmarshal(configOptions, &callConfig); err != nil { + return codersdk.ChatModelCallConfig{}, xerrors.Errorf("parse model config options: %w", err) + } + return callConfig, nil +} + // mergeConfigBetaHeaders never mutates extraHeaders; existing entries win // over config-derived ones. func mergeConfigBetaHeaders( extraHeaders map[string]string, providerHint string, - configOptions json.RawMessage, -) (map[string]string, error) { - if len(configOptions) == 0 { - return extraHeaders, nil - } - var callConfig codersdk.ChatModelCallConfig - if err := json.Unmarshal(configOptions, &callConfig); err != nil { - return nil, xerrors.Errorf("parse model config options: %w", err) - } + callConfig codersdk.ChatModelCallConfig, +) map[string]string { betaHeaders := chatprovider.BetaHeadersFromCallConfig(providerHint, &callConfig) if len(betaHeaders) == 0 { - return extraHeaders, nil + return extraHeaders } merged := make(map[string]string, len(extraHeaders)+len(betaHeaders)) for name, value := range betaHeaders { @@ -206,7 +212,7 @@ func mergeConfigBetaHeaders( for name, value := range extraHeaders { merged[name] = value } - return merged, nil + return merged } type aibridgeFantasyConfig struct { diff --git a/coderd/x/chatd/model_routing_internal_test.go b/coderd/x/chatd/model_routing_internal_test.go index 730522e6b9435..317160c7c9ea3 100644 --- a/coderd/x/chatd/model_routing_internal_test.go +++ b/coderd/x/chatd/model_routing_internal_test.go @@ -365,6 +365,78 @@ func TestAIGatewayModelForwardsProviderAuth(t *testing.T) { }) } +func TestAIGatewayModelAppliesResponsesAPIOverride(t *testing.T) { + t.Parallel() + + newServer := func(t *testing.T, paths chan string) *Server { + t.Helper() + factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) { + paths <- req.URL.Path + body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` + if strings.HasSuffix(req.URL.Path, "/chat/completions") { + body = `{"id":"chatcmpl_test","object":"chat.completion","created":0,"model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })} + return &Server{aibridgeTransportFactory: aibridgeTestFactoryPointer(factory)} + } + + configOptions := func(t *testing.T, useResponsesAPI *bool) json.RawMessage { + t.Helper() + raw, err := json.Marshal(codersdk.ChatModelCallConfig{ + OpenAIConfig: &codersdk.ChatModelOpenAIConfig{UseResponsesAPI: useResponsesAPI}, + }) + require.NoError(t, err) + return raw + } + + forceResponses := true + forceCompletions := false + + tests := []struct { + name string + model string + override *bool + wantPath string + }{ + {name: "ForceResponsesOnUnknownModel", model: "gpt-9-brand-new", override: &forceResponses, wantPath: "/v1/responses"}, + {name: "ForceCompletionsOnKnownModel", model: "gpt-4o", override: &forceCompletions, wantPath: "/v1/chat/completions"}, + {name: "UnsetKeepsKnownModelList", model: "gpt-4o", override: nil, wantPath: "/v1/responses"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + paths := make(chan string, 1) + server := newServer(t, paths) + provider := aibridgeTestAIProvider(uuid.New(), "primary-openai", database.AIProviderTypeOpenai) + req := aibridgeTestRequest(database.Chat{ID: uuid.New(), OwnerID: uuid.New()}, tt.model) + req.ConfigOptions = configOptions(t, tt.override) + + model, err := server.newModel( + t.Context(), + req, + aibridgeTestRoute(provider), + modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, + ) + require.NoError(t, err) + _, err = model.Generate(t.Context(), fantasy.Call{Prompt: []fantasy.Message{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}}, + }}}) + require.NoError(t, err) + + require.Equal(t, tt.wantPath, <-paths) + }) + } +} + func TestAIBridgeRoutingFailClosed(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index dfbcfac6e5ba9..fc18006bfe512 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -143,6 +143,7 @@ type shortTextCandidate struct { route aiGatewayModelRoute lm fantasy.LanguageModel providerOptions fantasy.ProviderOptions + configOptions json.RawMessage } func selectPreferredConfiguredShortTextModelConfig( @@ -320,6 +321,7 @@ func (p *Server) maybeGenerateChatTitle( route: overrideRoute, lm: overrideModel, providerOptions: p.titleGenerationProviderOptions(ctx, overrideModel, overrideConfig), + configOptions: overrideConfig.Options, } } else { candidate = shortTextCandidate{ @@ -328,6 +330,7 @@ func (p *Server) maybeGenerateChatTitle( route: fallbackRoute, lm: fallbackModel, providerOptions: p.titleGenerationProviderOptions(ctx, fallbackModel, fallbackConfig), + configOptions: fallbackConfig.Options, } } @@ -422,11 +425,17 @@ func (p *Server) titleGenerationProviderOptions( ) } } - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions) + responsesOverride := chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig) + providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( + model, + callConfig.ProviderOptions, + responsesOverride, + ) return chatprovider.ApplyReasoningEffort( model, providerOptions, chatprovider.ResolveReasoningEffort(nil, callConfig.ReasoningEffort), + responsesOverride, ) } @@ -438,14 +447,16 @@ func (p *Server) newQuickgenDebugModel( model string, route aiGatewayModelRoute, modelOpts modelBuildOptions, + configOptions json.RawMessage, ) (fantasy.LanguageModel, error) { debugOpts := modelOpts debugOpts.RecordHTTP = true debugModel, err := p.newModel(ctx, modelClientRequest{ - Chat: chat, - ModelName: model, - UserAgent: chatprovider.UserAgent(), - ExtraHeaders: chatprovider.CoderHeaders(chat), + Chat: chat, + ModelName: model, + UserAgent: chatprovider.UserAgent(), + ExtraHeaders: chatprovider.CoderHeaders(chat), + ConfigOptions: configOptions, }, route, debugOpts) if err != nil { return nil, err @@ -484,6 +495,7 @@ func (p *Server) prepareQuickgenDebugCandidate( candidate.model, candidate.route, modelOpts, + candidate.configOptions, ) if err != nil { logger.Warn(ctx, "failed to build short-text debug model", @@ -1332,6 +1344,7 @@ func (p *Server) generateTurnStatusLabel( fallbackModel fantasy.LanguageModel, fallbackRoute aiGatewayModelRoute, modelOpts modelBuildOptions, + configOptions json.RawMessage, logger slog.Logger, debugSvc *chatdebug.Service, triggerMessageID int64, @@ -1348,10 +1361,11 @@ func (p *Server) generateTurnStatusLabel( "\n\nAgent's latest message:\n" + assistantText candidate := shortTextCandidate{ - provider: fallbackProvider, - model: fallbackModelName, - route: fallbackRoute, - lm: fallbackModel, + provider: fallbackProvider, + model: fallbackModelName, + route: fallbackRoute, + lm: fallbackModel, + configOptions: configOptions, } statusSeedSummary := chatdebug.SeedSummary("Turn status label") diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index 530ec2cbd4846..615b7215a7a1e 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -1010,6 +1010,7 @@ func openAICompatTestModel(t *testing.T, baseURL string) fantasy.LanguageModel { chatprovider.UserAgent(), nil, nil, + nil, ) require.NoError(t, err) return model diff --git a/codersdk/chats.go b/codersdk/chats.go index 3afbaff70dae8..5addb91c9e183 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1509,9 +1509,16 @@ type ChatModelCallConfig struct { FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" description:"Penalty for tokens based on their frequency in the output"` Cost *ModelCostConfig `json:"cost,omitempty" description:"Optional pricing metadata for this model"` ReasoningEffort *ChatModelReasoningEffortConfig `json:"reasoning_effort,omitempty" description:"Default and max reasoning effort for the model"` + OpenAIConfig *ChatModelOpenAIConfig `json:"openai_config,omitempty" description:"OpenAI client construction settings" providers:"openai"` ProviderOptions *ChatModelProviderOptions `json:"provider_options,omitempty" description:"Provider-specific option overrides"` } +// ChatModelOpenAIConfig holds settings applied once when the OpenAI client +// is built, not per request. +type ChatModelOpenAIConfig struct { + UseResponsesAPI *bool `json:"use_responses_api,omitempty" label:"Use Responses API" description:"Override which OpenAI API this model uses. Leave unset to decide from the provider SDK's known-model list, true to force the Responses API, false to force Chat Completions. Azure OpenAI providers ignore this and always follow the known-model list."` +} + // UnmarshalJSON accepts both the current nested cost object and the previous // top-level pricing keys so legacy stored model_config JSON continues to load. func (c *ChatModelCallConfig) UnmarshalJSON(data []byte) error { diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index 03059c13216ae..461467eef3e62 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -584,6 +584,28 @@ func TestChatModelCallConfig_UnmarshalStrict(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(`{"bogus_setting": true}`), &decoded)) } +func TestChatModelCallConfig_UseResponsesAPIRoundTrip(t *testing.T) { + t.Parallel() + + var decoded codersdk.ChatModelCallConfig + err := decoded.UnmarshalStrict([]byte(`{"openai_config": {"use_responses_api": true}}`)) + require.NoError(t, err) + require.NotNil(t, decoded.OpenAIConfig) + require.NotNil(t, decoded.OpenAIConfig.UseResponsesAPI) + require.True(t, *decoded.OpenAIConfig.UseResponsesAPI) + + raw, err := json.Marshal(decoded) + require.NoError(t, err) + require.Contains(t, string(raw), `"use_responses_api":true`) + + var unset codersdk.ChatModelCallConfig + require.NoError(t, unset.UnmarshalStrict([]byte(`{"openai_config": {}}`))) + require.Nil(t, unset.OpenAIConfig.UseResponsesAPI) + raw, err = json.Marshal(unset) + require.NoError(t, err) + require.NotContains(t, string(raw), "use_responses_api") +} + func TestChatCostSummary_JSONRoundTrip(t *testing.T) { t.Parallel() diff --git a/scripts/modeloptionsgen/main.go b/scripts/modeloptionsgen/main.go index 89b6b0ffa9448..1de04e1676a23 100644 --- a/scripts/modeloptionsgen/main.go +++ b/scripts/modeloptionsgen/main.go @@ -15,17 +15,18 @@ import ( // SchemaField describes a single form field in the generated schema. type SchemaField struct { - JSONName string `json:"json_name"` - GoName string `json:"go_name"` - Type string `json:"type"` - Description string `json:"description,omitempty"` - Label string `json:"label,omitempty"` - Required bool `json:"required"` - Enum []string `json:"enum,omitempty"` - InputType string `json:"input_type"` - Hidden bool `json:"hidden,omitempty"` - VisibleWhen string `json:"visible_when,omitempty"` - ConflictsWith []string `json:"conflicts_with,omitempty"` + JSONName string `json:"json_name"` + GoName string `json:"go_name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Label string `json:"label,omitempty"` + Required bool `json:"required"` + Enum []string `json:"enum,omitempty"` + InputType string `json:"input_type"` + Hidden bool `json:"hidden,omitempty"` + VisibleWhen string `json:"visible_when,omitempty"` + ConflictsWith []string `json:"conflicts_with,omitempty"` + VisibleForProviders []string `json:"visible_for_providers,omitempty"` } // FieldGroup holds the fields for a struct or provider. @@ -55,6 +56,7 @@ func main() { reflect.TypeOf(codersdk.ChatModelCallConfig{}), "", map[string]bool{"ProviderOptions": true}, + nil, ) if err := validateFieldReferences("general", schema.General); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) @@ -76,13 +78,18 @@ func main() { } for _, p := range providerTypes { - schema.Providers[p.key] = extractFields(p.typ, "", nil) + schema.Providers[p.key] = extractFields(p.typ, "", nil, nil) if err := validateFieldReferences(p.key, schema.Providers[p.key]); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } } + if err := validateProviderScopes(schema); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + out, err := json.MarshalIndent(schema, "", "\t") if err != nil { _, _ = fmt.Fprintf(os.Stderr, "marshal schema: %v\n", err) @@ -94,6 +101,24 @@ func main() { _, _ = fmt.Println(string(out)) } +// validateProviderScopes rejects a providers tag naming something that is +// neither a canonical provider nor an alias, which would silently hide the +// field from every editor. +func validateProviderScopes(schema Schema) error { + for _, f := range schema.General.Fields { + for _, provider := range f.VisibleForProviders { + if _, ok := schema.Providers[provider]; ok { + continue + } + if _, ok := schema.ProviderAliases[provider]; ok { + continue + } + return xerrors.Errorf("field %q has providers entry %q referencing an unknown provider", f.JSONName, provider) + } + } + return nil +} + func validateFieldReferences(group string, fg FieldGroup) error { names := make(map[string]bool, len(fg.Fields)) for _, f := range fg.Fields { @@ -113,10 +138,9 @@ func validateFieldReferences(group string, fg FieldGroup) error { return nil } -// extractFields walks the struct fields of t and returns a FieldGroup. -// prefix is used to build dot-separated json_name values for nested -// structs. skip lists Go field names to exclude from output. -func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGroup { +// Nested fields inherit an enclosing provider scope unless they declare their +// own. +func extractFields(t reflect.Type, prefix string, skip map[string]bool, providers []string) FieldGroup { var fields []SchemaField for i := range t.NumField() { @@ -150,6 +174,11 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro // so that entire sub-objects can be marked hidden. hidden := f.Tag.Get("hidden") == "true" + fieldProviders := providers + if tag := f.Tag.Get("providers"); tag != "" { + fieldProviders = strings.Split(tag, ",") + } + // decimal.Decimal is an opaque numeric type used for pricing // precision; do not recurse into its internal struct fields. isDecimal := ft == reflect.TypeOf(decimal.Decimal{}) @@ -159,7 +188,7 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro // entire struct is marked hidden, in which case emit it // as a single opaque field. if ft.Kind() == reflect.Struct && !hidden && !isDecimal { - nested := extractFields(ft, fullJSONName, nil) + nested := extractFields(ft, fullJSONName, nil, fieldProviders) fields = append(fields, nested.Fields...) continue } @@ -184,17 +213,18 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro inputType := inferInputType(typeName, enumValues) fields = append(fields, SchemaField{ - JSONName: fullJSONName, - GoName: goFieldPath(prefix, f.Name, t, fullJSONName), - Type: typeName, - Description: description, - Label: label, - Required: required, - Enum: enumValues, - InputType: inputType, - Hidden: hidden, - VisibleWhen: visibleWhen, - ConflictsWith: conflictsWith, + JSONName: fullJSONName, + GoName: goFieldPath(prefix, f.Name, t, fullJSONName), + Type: typeName, + Description: description, + Label: label, + Required: required, + Enum: enumValues, + InputType: inputType, + Hidden: hidden, + VisibleWhen: visibleWhen, + ConflictsWith: conflictsWith, + VisibleForProviders: fieldProviders, }) } diff --git a/site/src/api/chatModelOptions.ts b/site/src/api/chatModelOptions.ts index 2f11abf2ad1b3..0b0dd79d3096e 100644 --- a/site/src/api/chatModelOptions.ts +++ b/site/src/api/chatModelOptions.ts @@ -25,6 +25,8 @@ export interface FieldSchema { hidden?: boolean; visible_when?: string; conflicts_with?: string[]; + /** Raw provider types the field applies to; absent means every provider. */ + visible_for_providers?: string[]; } /** @@ -179,7 +181,21 @@ export function getVisibleProviderFields(provider: string): FieldSchema[] { return getProviderFields(provider).filter((f) => !f.hidden); } -/** Get only the visible (non-hidden) general fields. */ -export function getVisibleGeneralFields(): FieldSchema[] { - return getGeneralFields().filter((f) => !f.hidden); +/** Matches the raw provider type, not {@link resolveProvider}, so aliases + * like "azure" do not inherit client construction fields. */ +export function isFieldVisibleForProvider( + field: FieldSchema, + provider: string, +): boolean { + const scope = field.visible_for_providers; + if (!scope || scope.length === 0) { + return true; + } + return scope.includes(provider.trim().toLowerCase()); +} + +export function getVisibleGeneralFields(provider: string): FieldSchema[] { + return getGeneralFields().filter( + (f) => !f.hidden && isFieldVisibleForProvider(f, provider), + ); } diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index 022202d5fb1ae..ca7594ddeccbe 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -100,6 +100,16 @@ "required": false, "enum": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], "input_type": "select" + }, + { + "json_name": "openai_config.use_responses_api", + "go_name": "OpenaiConfig.UseResponsesAPI", + "type": "boolean", + "description": "Override which OpenAI API this model uses. Leave unset to decide from the provider SDK's known-model list, true to force the Responses API, false to force Chat Completions. Azure OpenAI providers ignore this and always follow the known-model list.", + "label": "Use Responses API", + "required": false, + "input_type": "select", + "visible_for_providers": ["openai"] } ] }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5dadf27b2b235..45f18289442f2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2858,6 +2858,7 @@ export interface ChatModelCallConfig { readonly frequency_penalty?: number; readonly cost?: ModelCostConfig; readonly reasoning_effort?: ChatModelReasoningEffortConfig; + readonly openai_config?: ChatModelOpenAIConfig; readonly provider_options?: ChatModelProviderOptions; } @@ -2922,6 +2923,15 @@ export interface ChatModelOpenAICompatProviderOptions { readonly user?: string; } +// From codersdk/chats.go +/** + * ChatModelOpenAIConfig holds settings applied once when the OpenAI client + * is built, not per request. + */ +export interface ChatModelOpenAIConfig { + readonly use_responses_api?: boolean; +} + // From codersdk/chats.go /** * ChatModelOpenAIProviderOptions configures OpenAI provider behavior. diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 40b59864bb25e..25cb065559182 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -4,6 +4,7 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { withToaster } from "#/testHelpers/storybook"; import { MockAnthropicProviderState, + MockAzureProviderState, MockDisabledProviderState, MockOpenAIProviderState, mockGPT5, @@ -262,6 +263,29 @@ export const EditSaveSubmits: Story = { }, }; +export const EditClearingLastOptionSendsEmptyConfig: Story = { + args: { + editingModel: { ...mockGPT5, model_config: { temperature: 1 } }, + onDeleteModel: fn(async () => undefined), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /advanced/i })); + const temperature = canvas.getByLabelText(/temperature/i); + await expect(temperature).toHaveValue("1"); + await userEvent.clear(temperature); + await userEvent.click( + canvas.getByRole("button", { name: /^update model$/i }), + ); + // The empty object must be sent explicitly: omitting model_config + // preserves the stored options server-side. + await expect(args.onUpdateModel).toHaveBeenCalledWith( + mockGPT5.id, + expect.objectContaining({ model_config: {} }), + ); + }, +}; + export const EditUpdateDisabledUntilDirty: Story = { args: { editingModel: mockGPT5, @@ -398,3 +422,55 @@ export const CostTrackingExpanded: Story = { await userEvent.click(toggle); }, }; + +export const UseResponsesAPIForOpenAI: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5"); + await userEvent.type(canvas.getByLabelText(/context limit/i), "200000"); + await userEvent.click(canvas.getByRole("button", { name: /advanced/i })); + + const toggle = canvas.getByRole("radiogroup", { + name: /use responses api/i, + }); + await userEvent.click(within(toggle).getByRole("radio", { name: "On" })); + await userEvent.click(canvas.getByRole("button", { name: /add model/i })); + await expect(args.onCreateModel).toHaveBeenCalledWith( + expect.objectContaining({ + model_config: expect.objectContaining({ + openai_config: { use_responses_api: true }, + }), + }), + ); + }, +}; + +export const UseResponsesAPIHiddenForAnthropic: Story = { + args: { + selectedProviderState: MockAnthropicProviderState, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /advanced/i })); + await expect( + canvas.queryByRole("radiogroup", { name: /use responses api/i }), + ).not.toBeInTheDocument(); + }, +}; + +// Azure resolves to the OpenAI option schema through provider_aliases, so +// scoping has to gate on the raw provider type to keep this control out. +export const UseResponsesAPIHiddenForAzure: Story = { + args: { + providerStates: [MockOpenAIProviderState, MockAzureProviderState], + selectedProviderState: MockAzureProviderState, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /advanced/i })); + await expect( + canvas.queryByRole("radiogroup", { name: /use responses api/i }), + ).not.toBeInTheDocument(); + await expect(canvas.getByLabelText(/temperature/i)).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx index 40424dbb747dc..366b32914c730 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx @@ -171,7 +171,10 @@ export const ModelForm: FC = ({ ...(values.isDefault !== editingModel.is_default && { is_default: values.isDefault, }), - model_config: builtModelConfig, + // An omitted model_config preserves the stored options + // server-side, so clearing the last field must send an + // explicit empty config to replace them. + model_config: builtModelConfig ?? {}, }; await onUpdateModel(editingModel.id, req); diff --git a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts index 5ec2b3591b97e..4809795348917 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts +++ b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts @@ -106,6 +106,20 @@ export const MockBedrockProviderState: ProviderState = { modelConfigs: [mockBedrockClaude], }; +export const MockAzureProviderState: ProviderState = { + ...MockOpenAIProviderState, + key: "prov-azure", + provider: "azure", + label: "Azure OpenAI", + providerConfig: { + ...MockOpenAIProviderConfig, + id: "prov-azure", + provider: "azure", + display_name: "Azure OpenAI", + }, + modelConfigs: [], +}; + const MockDisabledProviderConfig: ChatProviderConfig = { ...MockOpenAIProviderConfig, id: "prov-openai-disabled", diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx index 6d709a67f4f88..f6fd25f3f5076 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx @@ -623,11 +623,12 @@ export const ModelConfigFields: FC = ({ * InputGroup for a compact, readable layout. */ export const PricingModelConfigFields: FC = ({ + provider, form, fieldErrors, disabled, }) => { - const fields = getVisibleGeneralFields().filter(({ json_name }) => + const fields = getVisibleGeneralFields(provider).filter(({ json_name }) => pricingFieldNames.has(json_name), ); @@ -680,12 +681,13 @@ export const PricingModelConfigFields: FC = ({ /** Reasoning effort selects, outside Advanced. */ export const ReasoningEffortConfigFields: FC = ({ + provider, form, fieldErrors, disabled, }) => { const ctx: FieldRenderContext = { form, fieldErrors, disabled }; - const fields = getVisibleGeneralFields() + const fields = getVisibleGeneralFields(provider) .filter(({ json_name }) => isReasoningEffortField(json_name)) .reverse(); @@ -717,12 +719,13 @@ export const ReasoningEffortConfigFields: FC = ({ /** See ReasoningEffortConfigFields for reasoning effort fields. */ export const GeneralModelConfigFields: FC = ({ + provider, form, fieldErrors, disabled, }) => { const ctx: FieldRenderContext = { form, fieldErrors, disabled }; - const fields = getVisibleGeneralFields().filter( + const fields = getVisibleGeneralFields(provider).filter( ({ json_name }) => !pricingFieldNames.has(json_name) && !isReasoningEffortField(json_name), ); @@ -737,22 +740,19 @@ export const GeneralModelConfigFields: FC = ({ .map(snakeToCamel) .join("."); const fieldKey = `config.${camelName}`; - const label = snakeToPrettyLabel(field); return ( - + className={cn("min-w-0", colSpanClass[colSpan(field)])} + > + + ); })} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts index 67bb3bc330590..23e74c1f72e66 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts @@ -1362,3 +1362,26 @@ describe("isFieldConflictDisabled", () => { expect(isFieldConflictDisabled(field(), reader)).toBe(false); }); }); + +describe("provider-scoped general fields", () => { + const form = formWith({ openaiConfig: { useResponsesApi: "true" } }); + + it("serializes a scoped general field for its provider", () => { + const result = buildModelConfigFromForm("openai", form); + expect(result.modelConfig?.openai_config).toEqual({ + use_responses_api: true, + }); + }); + + it("omits a scoped general field for another provider", () => { + const result = buildModelConfigFromForm("anthropic", form); + expect(result.modelConfig).toBeUndefined(); + }); + + // azure resolves to the openai option schema, so scoping must gate on the + // raw provider type. + it("omits a scoped general field for an aliased provider", () => { + const result = buildModelConfigFromForm("azure", form); + expect(result.modelConfig).toBeUndefined(); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts index b2072ac39ac7d..2d47c33fdec6d 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts @@ -4,6 +4,7 @@ import { getGeneralFields, getProviderFields, getProviderNames, + isFieldVisibleForProvider, resolveProvider, snakeToCamel, } from "#/api/chatModelOptions"; @@ -542,8 +543,9 @@ export const buildModelConfigFromForm = ( fieldErrors, ); + const rawProvider = (provider ?? "").trim().toLowerCase(); // Resolve the canonical provider name through the alias table. - const resolved = resolveProvider((provider ?? "").trim().toLowerCase()); + const resolved = resolveProvider(rawProvider); // Validate provider-specific fields. const providerFormState = form[resolved]; @@ -564,6 +566,9 @@ export const buildModelConfigFromForm = ( const modelConfig: Record = {}; for (const field of getGeneralFields()) { + // Skip fields scoped to other providers so stale values left in form + // state are not serialized. + if (!isFieldVisibleForProvider(field, rawProvider)) continue; const camelSegments = field.json_name.split(".").map(snakeToCamel); const formValue = deepGet(form, camelSegments); if (typeof formValue !== "string") continue;