mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
185 lines
5.3 KiB
Go
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"))
|
|
})
|
|
}
|
|
}
|