mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
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.
191 lines
5.7 KiB
Go
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"))
|
|
})
|
|
}
|
|
}
|