Files
coder/coderd/x/chatd/chatprovider/responses_api_test.go
T
Michael Suchacz 0e16e356b0 refactor(coderd/x/chatd): read the OpenAI transport from the model (#27704)
Stacked on #27703.

Provider option conversion, reasoning effort injection, and file part
acceptance each recomputed the OpenAI wire format from `(provider,
modelID, override)`. They now read it from `chatprovider.Model`, so a
decision cannot drift from the client it was built for.

`ProviderOptionsFromChatConfig` takes a `Transport`,
`ApplyReasoningEffort` takes a `Model`, and `AcceptsFilePartMediaType`
becomes a `Model` method. `UsesResponsesAPI` and `UsesResponsesOptions`
are deleted. The override extraction is unexported and reachable only
from `ModelFromConfig`, which now takes the model's
`ChatModelOpenAIConfig` directly, removing the six scattered extractions
at call sites.

That also resolves the computer-use mismatch. The computer-use model is
a hardcoded default with no config row of its own: its client was built
without an override while request preparation applied the chat model's.
Preparation now reads the computer-use model's own transport, so the two
agree without one model's client settings following a different model.
Passing the chat model's `openai_config` into the computer-use client
would have made them agree on the wrong value.

`TestModelTransportConsumersAgree` pins the invariant in one test: the
HTTP path the client actually hits, the concrete provider option struct
type, the type created by reasoning effort, and text/image file
acceptance.

> Mux prepared this PR on Mike's behalf.
2026-08-04 08:00:19 +00:00

185 lines
5.3 KiB
Go

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,
&codersdk.ChatModelOpenAIConfig{UseResponsesAPI: tc.override},
)
require.NoError(t, err)
_, err = model.LanguageModel().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)
})
}
}
// The wire path the client actually uses, the provider option struct type, and
// file-part acceptance must all agree, because a mismatch is silent: the SDK
// type-asserts the concrete option struct, and Responses accepts only images
// and PDFs natively.
func TestModelTransportConsumersAgree(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
serviceTier := "auto"
cases := []struct {
name string
modelID string
override *bool
wantPath string
wantOptions fantasy.ProviderOptionsData
wantAcceptText bool
}{
{
name: "ForceResponsesOnUnknownModel",
modelID: nonResponsesModel,
override: &forceResponses,
wantPath: "/responses",
wantOptions: &fantasyopenai.ResponsesProviderOptions{},
},
{
name: "ForceCompletionsOnKnownModel",
modelID: responsesModel,
override: &forceCompletions,
wantPath: "/chat/completions",
wantOptions: &fantasyopenai.ProviderOptions{},
wantAcceptText: true,
},
{
name: "UnsetFollowsKnownModelList",
modelID: responsesModel,
wantPath: "/responses",
wantOptions: &fantasyopenai.ResponsesProviderOptions{},
},
}
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.modelID,
chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{fantasyopenai.Name: "test-key"},
BaseURLByProvider: map[string]string{fantasyopenai.Name: serverURL},
},
chatprovider.UserAgent(),
nil,
nil,
&codersdk.ChatModelOpenAIConfig{UseResponsesAPI: tc.override},
)
require.NoError(t, err)
_, err = model.LanguageModel().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()
require.Equal(t, tc.wantPath, gotPath)
mu.Unlock()
options := chatprovider.ProviderOptionsFromChatModelConfig(model, &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier},
})
require.IsType(t, tc.wantOptions, options[fantasyopenai.Name])
effortOptions := chatprovider.ApplyReasoningEffort(
model,
nil,
new(codersdk.ChatModelReasoningEffortHigh),
)
require.IsType(t, tc.wantOptions, effortOptions[fantasyopenai.Name])
require.Equal(t, tc.wantAcceptText, model.AcceptsFilePartMediaType("text/plain"))
require.True(t, model.AcceptsFilePartMediaType("image/png"))
})
}
}