From 323675fca11a67df164b394ef8fb0b2f96897e03 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:07:01 +0000 Subject: [PATCH 1/2] feat: add per-model OpenAI Responses API toggle OpenAI models speak either the Responses API or Chat Completions, and the provider SDK picks per model from a static known-model list. A model absent from that list falls back to Chat Completions with no way to opt in. Add `openai_config.use_responses_api` to the model call config: unset keeps the known-model list, true forces Responses, false forces Chat Completions. It lives in a construction-time section rather than in `provider_options.openai` because it selects the API instead of being a request parameter. The transport decision must agree with the client that was built, so the override is threaded to provider option conversion, reasoning effort injection, and file part acceptance, and to every path that builds its own OpenAI client. `chatopenai.UsesResponsesAPI` is the single predicate. The option schema generator gains a `providers` struct tag, emitted as `visible_for_providers`, so the model editor renders the control only for openai-typed providers. Gating on the raw provider type keeps it out of editors for aliased types such as azure, which cannot honor it. --- coderd/exp_chats.go | 5 + coderd/exp_chats_internal_test.go | 47 +++++ coderd/x/chatd/ARCHITECTURE.md | 45 +++++ coderd/x/chatd/chatd.go | 5 + coderd/x/chatd/chatopenai/options.go | 56 +++--- coderd/x/chatd/chatopenai/options_test.go | 43 ++++- .../chatd/chatopenai/transport_parity_test.go | 120 ++++++++++++ coderd/x/chatd/chatprovider/chatprovider.go | 44 +++-- .../x/chatd/chatprovider/chatprovider_test.go | 20 +- .../x/chatd/chatprovider/file_parts_test.go | 87 +++++---- .../openai_compat_patches_test.go | 1 + .../x/chatd/chatprovider/reasoningeffort.go | 3 +- .../chatprovider/reasoningeffort_test.go | 8 +- .../chatd/chatprovider/responses_api_test.go | 174 ++++++++++++++++++ coderd/x/chatd/chatprovider/useragent_test.go | 2 +- coderd/x/chatd/compaction_override.go | 38 ++-- .../compaction_override_internal_test.go | 6 +- coderd/x/chatd/compaction_sanitize.go | 2 + .../compaction_sanitize_internal_test.go | 8 +- coderd/x/chatd/generation.go | 1 + coderd/x/chatd/generation_preparer.go | 92 +++++---- .../generation_preparer_internal_test.go | 117 +++++++++++- coderd/x/chatd/model_routing.go | 2 + coderd/x/chatd/model_routing_aibridge.go | 30 +-- coderd/x/chatd/model_routing_internal_test.go | 72 ++++++++ coderd/x/chatd/quickgen.go | 32 +++- coderd/x/chatd/quickgen_internal_test.go | 1 + codersdk/chats.go | 7 + codersdk/chats_test.go | 22 +++ scripts/modeloptionsgen/main.go | 85 ++++++--- site/src/api/chatModelOptions.ts | 22 ++- site/src/api/chatModelOptionsGenerated.json | 10 + site/src/api/typesGenerated.ts | 10 + .../components/ModelForm.stories.tsx | 76 ++++++++ .../ModelsPage/components/ModelForm.tsx | 5 +- .../AISettingsPage/ModelsPage/testFixtures.ts | 14 ++ .../ChatModelAdminPanel/ModelConfigFields.tsx | 32 ++-- .../modelConfigFormLogic.test.ts | 23 +++ .../modelConfigFormLogic.ts | 7 +- 39 files changed, 1167 insertions(+), 207 deletions(-) create mode 100644 coderd/x/chatd/chatopenai/transport_parity_test.go create mode 100644 coderd/x/chatd/chatprovider/responses_api_test.go diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index a6f54bf80d3..3360c583971 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 bcd7624438e..7ac34ff850b 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 f2bfab782a6..4faf6e10155 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 ccc12ad80c4..6948c37310b 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 5623f768287..d8851ce187b 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 32e8a872045..6d44da4b19c 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 00000000000..90e7617d42e --- /dev/null +++ b/coderd/x/chatd/chatopenai/transport_parity_test.go @@ -0,0 +1,120 @@ +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" +) + +// openAIOptionTransportSupport records, per config field, which OpenAI wire +// formats honor it. It mirrors the table in ARCHITECTURE.md under "OpenAI +// transport selection". TestProviderOptionsTransportParity fails when a field +// is honored on one transport and silently ignored on the other without being +// recorded here, and when a new field is added without a row. +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": {}, +} + +// Each field carries a valid non-zero value that changes the converted output +// on every transport that honors the field. service_tier uses a tier fantasy +// declares no constant for, so the conversion cannot pass by matching +// constants only. +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) + } +} + +// optionChangesConvertedOutput reports whether converting options produces a +// different result than converting an empty config on the same transport. +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 ad450d6a542..ec55d922831 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -123,11 +123,12 @@ 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 { +// as a file content part rather than silently dropping it. modelID and +// openAIResponsesOverride together distinguish 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, openAIResponsesOverride *bool) bool { baseType := mediaType if parsed, _, err := mime.ParseMediaType(mediaType); err == nil { baseType = parsed @@ -140,7 +141,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 +151,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 +897,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 +922,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 +1011,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 +1117,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 +1129,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 b1b2a37a885..08433b26369 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 62d20e757e2..09f90331f75 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 abb97079c67..4b864c29241 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 95ee8fe24ef..31138cca4b2 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 d301c6d5893..a7c270bbc10 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 00000000000..30572568fc0 --- /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 7b4ba9319a7..fbe48f68f7f 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 0e3d4d48604..f784f074f61 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 9267c84446d..d325b91a899 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 9f4676b29f5..1f0b939cab6 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 f3b21f7bc10..7e18498baed 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 7e87da9a236..60048b2c6dd 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 fcbfbf744d0..d3552e83cc9 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 0004da960bc..8c73290ada0 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,118 @@ func TestPrepareGenerationClampsRequestedReasoningEffortToMax(t *testing.T) { require.Equal(t, fantasyopenai.ReasoningEffortMedium, *providerOptions.ReasoningEffort) } +// A computer-use turn swaps in a specialized model whose client is built +// without the chat config's openai_config, so provider options and file-part +// classification must follow the SDK's transport choice for that model, not +// the inherited override. +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 +370,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 +448,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 f67beb17976..5a07ace6230 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 2bd724869ae..85b4476ec2f 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 730522e6b94..317160c7c9e 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 dfbcfac6e5b..fc18006bfe5 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 530ec2cbd48..615b7215a7a 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 3afbaff70da..5addb91c9e1 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 03059c13216..461467eef3e 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 89b6b0ffa94..b878872b528 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 { @@ -115,8 +140,10 @@ func validateFieldReferences(group string, fg FieldGroup) error { // 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 { +// structs. skip lists Go field names to exclude from output. providers +// carries the enclosing field's provider scope down to nested leaves, +// so tagging a sub-struct scopes everything inside it. +func extractFields(t reflect.Type, prefix string, skip map[string]bool, providers []string) FieldGroup { var fields []SchemaField for i := range t.NumField() { @@ -150,6 +177,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 +191,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 +216,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 2f11abf2ad1..0b0dd79d309 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 022202d5fb1..ca7594ddecc 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 5dadf27b2b2..45f18289442 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 40b59864bb2..25cb0655591 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 40424dbb747..366b32914c7 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 5ec2b3591b9..48097953489 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 6d709a67f4f..f6fd25f3f50 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 67bb3bc3305..23e74c1f72e 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 b2072ac39ac..2d47c33fdec 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; From 9a4c1e6748a6bc8fe985b907d98599fc26ac88f0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:22:48 +0000 Subject: [PATCH 2/2] chore: tighten comments --- .../x/chatd/chatopenai/transport_parity_test.go | 15 ++++----------- coderd/x/chatd/chatprovider/chatprovider.go | 9 +++------ .../x/chatd/generation_preparer_internal_test.go | 4 ---- scripts/modeloptionsgen/main.go | 7 ++----- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/coderd/x/chatd/chatopenai/transport_parity_test.go b/coderd/x/chatd/chatopenai/transport_parity_test.go index 90e7617d42e..ac1b24bfd1f 100644 --- a/coderd/x/chatd/chatopenai/transport_parity_test.go +++ b/coderd/x/chatd/chatopenai/transport_parity_test.go @@ -12,11 +12,8 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// openAIOptionTransportSupport records, per config field, which OpenAI wire -// formats honor it. It mirrors the table in ARCHITECTURE.md under "OpenAI -// transport selection". TestProviderOptionsTransportParity fails when a field -// is honored on one transport and silently ignored on the other without being -// recorded here, and when a new field is added without a row. +// 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 @@ -47,10 +44,8 @@ var openAIOptionTransportSupport = map[string]struct { "allowed_domains": {}, } -// Each field carries a valid non-zero value that changes the converted output -// on every transport that honors the field. service_tier uses a tier fantasy -// declares no constant for, so the conversion cannot pass by matching -// constants only. +// 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"), @@ -103,8 +98,6 @@ func TestProviderOptionsTransportParity(t *testing.T) { } } -// optionChangesConvertedOutput reports whether converting options produces a -// different result than converting an empty config on the same transport. func optionChangesConvertedOutput( t *testing.T, responsesOverride *bool, diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index ec55d922831..4929a1580ce 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -122,12 +122,9 @@ func InlineImageCapBytes(provider string) (int, bool) { } } -// AcceptsFilePartMediaType reports whether provider accepts mediaType -// as a file content part rather than silently dropping it. modelID and -// openAIResponsesOverride together distinguish 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. +// 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 { diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 8c73290ada0..9f3059aea29 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -160,10 +160,6 @@ func TestPrepareGenerationClampsRequestedReasoningEffortToMax(t *testing.T) { require.Equal(t, fantasyopenai.ReasoningEffortMedium, *providerOptions.ReasoningEffort) } -// A computer-use turn swaps in a specialized model whose client is built -// without the chat config's openai_config, so provider options and file-part -// classification must follow the SDK's transport choice for that model, not -// the inherited override. func TestPrepareGenerationComputerUseIgnoresChatTransportOverride(t *testing.T) { t.Parallel() diff --git a/scripts/modeloptionsgen/main.go b/scripts/modeloptionsgen/main.go index b878872b528..1de04e1676a 100644 --- a/scripts/modeloptionsgen/main.go +++ b/scripts/modeloptionsgen/main.go @@ -138,11 +138,8 @@ 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. providers -// carries the enclosing field's provider scope down to nested leaves, -// so tagging a sub-struct scopes everything inside it. +// 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