Files
coder/coderd/x/chatd/chatprovider/responses_api_test.go
T
Michael Suchacz 404bb2f663 refactor(coderd/x/chatd): own provider option construction in one function (#27705)
Stacked on #27704.

Provider option conversion and reasoning effort injection both create
OpenAI option structs, so each of the four call sites had to pair them
in the right order and pick the same transport for each.

`chatprovider.ProviderOptionsForCall` now owns both steps, and the two
helpers it wraps are unexported. The advisor, main generation,
compaction override, and quickgen paths each collapse to one call.

The ARCHITECTURE section on transport selection is updated to match:
`ProviderOptionsForCall` is described as the only entry point in
`chatprovider` that builds provider options for a call, delegating
OpenAI conversion to `chatopenai.ProviderOptionsFromChatConfig`, and it
now records that the quickgen turn status label and chat summary paths
deliberately send no provider options.

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

191 lines
5.7 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/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
)
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.ProviderOptionsForCall(model, codersdk.ChatModelCallConfig{
ProviderOptions: &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier},
},
}, nil)
require.IsType(t, tc.wantOptions, options[fantasyopenai.Name])
// Reasoning effort creates the option struct when the config has no
// OpenAI options of its own.
effortOptions := chatprovider.ProviderOptionsForCall(model, codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
},
}, nil)
require.IsType(t, tc.wantOptions, effortOptions[fantasyopenai.Name])
require.Equal(t, tc.wantAcceptText, model.AcceptsFilePartMediaType("text/plain"))
require.True(t, model.AcceptsFilePartMediaType("image/png"))
})
}
}