Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -858,17 +858,17 @@ During generation preparation, the effective effort is resolved as the chat's `l

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:
The transport is resolved exactly once, when the client is built, and carried on `chatprovider.Model` as a `chatopenai.Transport`. `Model` wraps the fantasy client with that resolved fact; its fields are unexported and only its constructor sets the transport, deriving it from the client, so no caller can pick a transport that disagrees with the client. `TransportInvalid` is the zero value and panics when read rather than defaulting to a wire format. A nil client yields that invalid zero value, which the construction path reports as an error.

- 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.
Request preparation reads the transport from the model instead of recomputing it. Three places depend on it, and each fails silently when it disagrees with the client:

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.
- Provider option conversion chooses between the Responses and Chat Completions option structs. The SDK type-asserts the concrete struct, so a mismatch discards every OpenAI provider option rather than failing.
- Reasoning effort injection creates those option structs when a config has no OpenAI options of its own.
- File part conversion (`Model.AcceptsFilePartMediaType`) gates attachments, because the Responses API natively accepts only images and PDFs. A mismatch here drops text attachments.

Client construction returns a `chatprovider.Model`, which pairs the fantasy client with the transport resolved from that client's own identity as a `chatopenai.Transport`. Its fields are unexported and only the constructor sets the transport, so no caller can pair a client with a transport it does not speak; a nil client yields the invalid zero value, which fails closed. Decorators such as debug recording replace the wrapped client through `Model.WithLanguageModel`, which preserves the resolved transport, because wrapping does not change what the client speaks. Request preparation does not read the carried transport yet; it still recomputes the decision from the override, and the wrapper is the authoritative value those recomputations must agree with.
Paths that build their own clients get a `Model` from the same constructor, including the compaction override, quick generation (used by turn status labels and debug models), and the advisor runtime. Debug recording replaces the wrapped client and preserves the resolved transport. Computer-use turns substitute a hardcoded default model that has no config of its own; it carries its own transport, so the chat model's `openai_config` does not follow it.

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.
Azure is deliberately exempt: its provider always enables the Responses API for known models and exposes no equivalent per-model hook, so the transport keeps following the known-model list for Azure. Ignoring the override there is what keeps the decisions above in agreement with the Azure client. The exemption is narrower than it appears, because chatd never builds an azure-typed provider as a fantasy azure client: `fantasyConfigForAIBridge` folds every provider type other than anthropic, bedrock, and openai into openai-compat, which always speaks Chat Completions.

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.

Expand Down
7 changes: 2 additions & 5 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,17 +428,14 @@ func (p *Server) newAdvisorRuntime(
nil,
advisorCallConfig.ReasoningEffort,
)
advisorResponsesOverride := chatprovider.OpenAIResponsesAPIOverride(advisorCallConfig.OpenAIConfig)
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(
advisorModel.LanguageModel(),
advisorModel,
advisorCallConfig.ProviderOptions,
advisorResponsesOverride,
)
providerOptions = chatprovider.ApplyReasoningEffort(
advisorModel.LanguageModel(),
advisorModel,
providerOptions,
advisorReasoningEffort,
advisorResponsesOverride,
)

rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Expand Down
20 changes: 2 additions & 18 deletions coderd/x/chatd/chatopenai/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ import (
// ProviderOptionsFromChatConfig converts chat model OpenAI options to fantasy
// provider options used for inference calls.
func ProviderOptionsFromChatConfig(
model fantasy.LanguageModel,
transport Transport,
options *codersdk.ChatModelOpenAIProviderOptions,
openAIResponsesOverride *bool,
) fantasy.ProviderOptionsData {
if UsesResponsesOptions(model, openAIResponsesOverride) {
if transport.UsesResponses() {
include := EnsureResponseIncludes(IncludeFromChat(options.Include))
providerOptions := &fantasyopenai.ResponsesProviderOptions{
Include: include,
Expand Down Expand Up @@ -116,21 +115,6 @@ 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 {
return TransportFor(provider, modelID, override).UsesResponses()
}

// UsesResponsesOptions reports whether the model should use OpenAI Responses
// API provider options.
func UsesResponsesOptions(model fantasy.LanguageModel, override *bool) bool {
if model == nil {
return false
}
return UsesResponsesAPI(model.Provider(), model.Model(), override)
}

// 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
Expand Down
107 changes: 2 additions & 105 deletions coderd/x/chatd/chatopenai/options_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package chatopenai_test

import (
"context"
"testing"

"charm.land/fantasy"
fantasyazure "charm.land/fantasy/providers/azure"
fantasyopenai "charm.land/fantasy/providers/openai"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -44,9 +41,8 @@ func TestProviderOptionsFromChatConfigLegacy(t *testing.T) {
}

got := chatopenai.ProviderOptionsFromChatConfig(
fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-3.5-turbo-instruct"},
chatopenai.TransportChatCompletions,
options,
nil,
)

providerOptions, ok := got.(*fantasyopenai.ProviderOptions)
Expand Down Expand Up @@ -97,9 +93,8 @@ func TestProviderOptionsFromChatConfigResponses(t *testing.T) {
}

got := chatopenai.ProviderOptionsFromChatConfig(
fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"},
chatopenai.TransportResponses,
options,
nil,
)

providerOptions, ok := got.(*fantasyopenai.ResponsesProviderOptions)
Expand Down Expand Up @@ -242,75 +237,6 @@ func TestEnsureResponseIncludes(t *testing.T) {
}
}

func TestUsesResponsesOptions(t *testing.T) {
t.Parallel()

forceResponses := true
forceCompletions := false

tests := []struct {
name string
model fantasy.LanguageModel
override *bool
want bool
}{
{name: "Nil"},
{
name: "OpenAIResponsesModel",
model: fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"},
want: true,
},
{
name: "AzureResponsesModel",
model: fakeLanguageModel{provider: fantasyazure.Name, model: "gpt-4.1"},
want: true,
},
{
name: "OpenAINonResponsesModel",
model: fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-3.5-turbo-instruct"},
},
{
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, tt.override)
require.Equal(t, tt.want, got)
})
}
}

func TestServiceTierFromChat(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -456,32 +382,3 @@ func requireTextVerbosityPointerValue(
func ptr[T any](value T) *T {
return &value
}

type fakeLanguageModel struct {
provider string
model string
}

func (fakeLanguageModel) Generate(context.Context, fantasy.Call) (*fantasy.Response, error) {
panic("not implemented")
}

func (fakeLanguageModel) Stream(context.Context, fantasy.Call) (fantasy.StreamResponse, error) {
panic("not implemented")
}

func (fakeLanguageModel) GenerateObject(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
panic("not implemented")
}

func (fakeLanguageModel) StreamObject(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
panic("not implemented")
}

func (f fakeLanguageModel) Provider() string {
return f.provider
}

func (f fakeLanguageModel) Model() string {
return f.model
}
11 changes: 5 additions & 6 deletions coderd/x/chatd/chatopenai/transport_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,23 @@ func TestProviderOptionsTransportParity(t *testing.T) {
reflect.ValueOf(options).Elem().Field(i).Set(sample)

require.Equalf(t, want.responses,
optionChangesConvertedOutput(t, ptr(true), options),
optionChangesConvertedOutput(t, chatopenai.TransportResponses, options),
"field %s on the Responses transport", name)
require.Equalf(t, want.chatCompletions,
optionChangesConvertedOutput(t, ptr(false), options),
optionChangesConvertedOutput(t, chatopenai.TransportChatCompletions, options),
"field %s on the Chat Completions transport", name)
}
}

func optionChangesConvertedOutput(
t *testing.T,
responsesOverride *bool,
transport chatopenai.Transport,
options *codersdk.ChatModelOpenAIProviderOptions,
) bool {
t.Helper()
model := fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"}
baseline := chatopenai.ProviderOptionsFromChatConfig(
model, &codersdk.ChatModelOpenAIProviderOptions{}, responsesOverride,
transport, &codersdk.ChatModelOpenAIProviderOptions{},
)
converted := chatopenai.ProviderOptionsFromChatConfig(model, options, responsesOverride)
converted := chatopenai.ProviderOptionsFromChatConfig(transport, options)
return !reflect.DeepEqual(baseline, converted)
}
40 changes: 19 additions & 21 deletions coderd/x/chatd/chatprovider/chatprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,11 @@ func InlineImageCapBytes(provider string) (int, bool) {
}
}

// AcceptsFilePartMediaType reports whether a provider transport accepts
// mediaType as a native file part. Unknown providers return false so callers
// can avoid silently dropping unsupported text-family content.
func AcceptsFilePartMediaType(provider, modelID, mediaType string, openAIResponsesOverride *bool) bool {
// AcceptsFilePartMediaType reports whether m's provider accepts mediaType as a
// file content part rather than silently dropping it. Callers replace rejected
// parts with text, so a false negative costs fidelity while a false positive
// loses the attachment entirely. Unknown providers therefore return false.
func (m Model) AcceptsFilePartMediaType(mediaType string) bool {
baseType := mediaType
if parsed, _, err := mime.ParseMediaType(mediaType); err == nil {
baseType = parsed
Expand All @@ -138,8 +139,7 @@ func AcceptsFilePartMediaType(provider, modelID, mediaType string, openAIRespons
isAudio := baseType == "audio/wav" || baseType == "audio/mpeg" || baseType == "audio/mp3"
isPDF := baseType == "application/pdf"

normalized := NormalizeProvider(provider)
switch normalized {
switch NormalizeProvider(m.Provider()) {
case fantasygoogle.Name:
// Google passes any file part through unfiltered.
return true
Expand All @@ -149,7 +149,7 @@ func AcceptsFilePartMediaType(provider, modelID, mediaType string, openAIRespons
return isImage || isText || isPDF
case fantasyopenai.Name, fantasyazure.Name:
// Chat Completions accepts text and audio as native file parts.
if chatopenai.UsesResponsesAPI(normalized, modelID, openAIResponsesOverride) {
if m.transport.UsesResponses() {
return isImage || isPDF
}
return isImage || isText || isAudio || isPDF
Expand Down Expand Up @@ -894,10 +894,11 @@ func BetaHeadersFromCallConfig(providerName string, config *codersdk.ChatModelCa
}
}

// OpenAIResponsesAPIOverride returns the configured OpenAI Responses API
// 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 {
// SDK's known-model list. It stays unexported so the decision is reachable
// only from client construction.
func openAIResponsesAPIOverride(config *codersdk.ChatModelOpenAIConfig) *bool {
if config == nil {
return nil
}
Expand All @@ -909,17 +910,16 @@ func OpenAIResponsesAPIOverride(config *codersdk.ChatModelOpenAIConfig) *bool {
// 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. 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.
// all provider HTTP requests. openAIConfig carries the model's OpenAI client
// settings, including the transport override applied here.
func ModelFromConfig(
providerHint string,
modelName string,
providerKeys ProviderAPIKeys,
userAgent string,
extraHeaders map[string]string,
httpClient *http.Client,
openAIResponsesOverride *bool,
openAIConfig *codersdk.ChatModelOpenAIConfig,
) (Model, error) {
provider, modelID, err := ResolveModelWithProviderHint(modelName, providerHint)
if err != nil {
Expand Down Expand Up @@ -1008,8 +1008,8 @@ func ModelFromConfig(
fantasyopenai.WithUseResponsesAPI(),
fantasyopenai.WithUserAgent(userAgent),
}
if openAIResponsesOverride != nil {
forced := *openAIResponsesOverride
if override := openAIResponsesAPIOverride(openAIConfig); override != nil {
forced := *override
options = append(options, fantasyopenai.WithResponsesAPIFunc(func(string) bool {
return forced
}))
Expand Down Expand Up @@ -1078,7 +1078,7 @@ func ModelFromConfig(
if err != nil {
return Model{}, xerrors.Errorf("load %s model: %w", provider, err)
}
return NewModel(model, openAIResponsesOverride), nil
return NewModel(model, openAIConfig), nil
}

func providerCreationError(provider string, err error) error {
Expand Down Expand Up @@ -1112,9 +1112,8 @@ func missingProviderAPIKeyError(provider string) error {
// ProviderOptionsFromChatModelConfig converts chat model provider options to
// fantasy provider options used for inference calls.
func ProviderOptionsFromChatModelConfig(
model fantasy.LanguageModel,
model Model,
options *codersdk.ChatModelProviderOptions,
openAIResponsesOverride *bool,
) fantasy.ProviderOptions {
if options == nil {
return nil
Expand All @@ -1124,9 +1123,8 @@ func ProviderOptionsFromChatModelConfig(

if options.OpenAI != nil {
result[fantasyopenai.Name] = chatopenai.ProviderOptionsFromChatConfig(
model,
model.transport,
options.OpenAI,
openAIResponsesOverride,
)
}
if options.Anthropic != nil {
Expand Down
4 changes: 2 additions & 2 deletions coderd/x/chatd/chatprovider/chatprovider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,11 +390,11 @@ func TestAnthropicThinkingDisplayFromChat(t *testing.T) {
func TestProviderOptionsFromChatModelConfig_AnthropicThinkingDisplay(t *testing.T) {
t.Parallel()

providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(nil, &codersdk.ChatModelProviderOptions{
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(chatprovider.Model{}, &codersdk.ChatModelProviderOptions{
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
ThinkingDisplay: ptr.Ref(" SUMMARIZED "),
},
}, nil)
})

require.NotNil(t, providerOptions)
anthropicOptions, ok := providerOptions[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions)
Expand Down
Loading
Loading