Files
coder/coderd/x/chatd/generation_preparer_internal_test.go
T
Michael Suchacz b5c9e8e471 refactor(coderd/x/chatd): carry the resolved OpenAI transport on a model wrapper (#27703)
Stacked on #27683.

The OpenAI wire format is decided when the client is built, then thrown
away, so downstream sites recompute it from `(provider, modelID,
override)`. Any disagreement fails silently: the SDK type-asserts the
concrete provider options struct and discards every OpenAI option, and
text attachments are dropped because Responses natively accepts only
images and PDFs.

This adds `chatprovider.Model`, which pairs a fantasy client with the
transport resolved from that client's own identity. Its fields are
unexported and only the constructor sets the transport, deriving it from
the client, so no caller can pick a transport that disagrees with the
client it wraps. `chatopenai.Transport`'s zero value is invalid and
panics when read rather than defaulting to a wire format, following the
existing precedent for construction invariants.

`Model` is threaded through construction, the resolve paths, and the
four struct fields that store a model for later request preparation.
Terminal call sites keep taking `fantasy.LanguageModel` and receive
`LanguageModel()`, which avoids a new package edge from `chatloop` and
`chatadvisor` into `chatprovider`.

No decisions move yet. The consumers still recompute the transport, and
`UsesResponsesAPI` now delegates to `TransportFor` so the two agree by
construction. #27704 makes the consumers read it from the model.

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

526 lines
18 KiB
Go

package chatd //nolint:testpackage // Exercises unexported re-derivation helpers.
import (
"encoding/json"
"strings"
"testing"
"charm.land/fantasy"
fantasyopenai "charm.land/fantasy/providers/openai"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
)
func mustMarshalText(t *testing.T, parts ...string) pqtype.NullRawMessage {
t.Helper()
messageParts := make([]codersdk.ChatMessagePart, 0, len(parts))
for _, p := range parts {
messageParts = append(messageParts, codersdk.ChatMessageText(p))
}
content, err := chatprompt.MarshalParts(messageParts)
require.NoError(t, err)
return content
}
func textMessage(t *testing.T, id int64, role database.ChatMessageRole, parts ...string) database.ChatMessage {
t.Helper()
return database.ChatMessage{
ID: id,
Role: role,
Content: mustMarshalText(t, parts...),
ContentVersion: chatprompt.CurrentContentVersion,
}
}
func TestLatestAssistantText(t *testing.T) {
t.Parallel()
t.Run("ReturnsMostRecentAssistantMessage", func(t *testing.T) {
t.Parallel()
messages := []database.ChatMessage{
textMessage(t, 1, database.ChatMessageRoleUser, "hi"),
textMessage(t, 2, database.ChatMessageRoleAssistant, "first answer"),
textMessage(t, 3, database.ChatMessageRoleTool, "tool result"),
textMessage(t, 4, database.ChatMessageRoleAssistant, " final answer "),
}
require.Equal(t, "final answer", latestAssistantText(messages))
})
t.Run("ConcatenatesTextParts", func(t *testing.T) {
t.Parallel()
messages := []database.ChatMessage{
textMessage(t, 1, database.ChatMessageRoleAssistant, "foo", "bar"),
}
require.Equal(t, "foobar", latestAssistantText(messages))
})
t.Run("NoAssistantMessage", func(t *testing.T) {
t.Parallel()
messages := []database.ChatMessage{
textMessage(t, 1, database.ChatMessageRoleUser, "hi"),
textMessage(t, 2, database.ChatMessageRoleTool, "tool result"),
}
require.Empty(t, latestAssistantText(messages))
})
t.Run("EmptyAssistantText", func(t *testing.T) {
t.Parallel()
messages := []database.ChatMessage{
textMessage(t, 1, database.ChatMessageRoleAssistant, " "),
}
require.Empty(t, latestAssistantText(messages))
})
t.Run("EmptyHistory", func(t *testing.T) {
t.Parallel()
require.Empty(t, latestAssistantText(nil))
})
}
func TestPrepareGenerationClampsRequestedReasoningEffortToMax(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: org.ID,
})
provider := dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{
Type: database.AIProviderTypeOpenai,
}, "test-key")
modelConfigRaw, err := json.Marshal(codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(codersdk.ChatModelReasoningEffortLow),
Max: ptr.Ref(codersdk.ChatModelReasoningEffortMedium),
},
})
require.NoError(t, err)
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini",
Options: modelConfigRaw,
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
}, func(p *database.InsertChatModelConfigParams) {
p.Enabled = true
})
created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: modelConfig.ID,
Title: "clamp reasoning effort",
ClientType: database.ChatClientTypeApi,
InitialMessages: []chatstate.Message{
{
Role: database.ChatMessageRoleUser,
Content: mustMarshalText(t, "hello"),
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
ReasoningEffort: database.NullChatReasoningEffort{
ChatReasoningEffort: database.ChatReasoningEffortHigh,
Valid: true,
},
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ContentVersion: chatprompt.CurrentContentVersion,
},
},
})
require.NoError(t, err)
server := newInternalTestServer(
t,
db,
ps,
chatprovider.ProviderAPIKeys{},
withInternalTestServerTransportFactory(&aibridgeTestFactory{}),
)
prepared, err := server.prepareGeneration(ctx, generationPrepareInput{
Chat: created.Chat,
Messages: created.InitialMessages,
})
require.NoError(t, err)
t.Cleanup(prepared.Cleanup)
providerOptions, ok := prepared.ProviderOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
require.True(t, ok, "%T", prepared.ProviderOptions[fantasyopenai.Name])
require.NotNil(t, providerOptions.ReasoningEffort)
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *providerOptions.ReasoningEffort)
}
func TestPrepareGenerationComputerUseIgnoresChatTransportOverride(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: org.ID,
})
require.NoError(t, db.UpsertChatComputerUseProvider(ctx, string(codersdk.ChatComputerUseProviderOpenAI)))
provider := dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{
Type: database.AIProviderTypeOpenai,
}, "test-key")
forceCompletions := false
modelConfigRaw, err := json.Marshal(codersdk.ChatModelCallConfig{
OpenAIConfig: &codersdk.ChatModelOpenAIConfig{
UseResponsesAPI: &forceCompletions,
},
ProviderOptions: &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
User: ptr.Ref("computer-use"),
},
},
})
require.NoError(t, err)
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini",
Options: modelConfigRaw,
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
}, func(p *database.InsertChatModelConfigParams) {
p.Enabled = true
})
const attachmentText = "text attachment body"
file, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: user.ID,
OrganizationID: org.ID,
Name: "notes.txt",
Mimetype: "text/plain",
Data: []byte(attachmentText),
})
require.NoError(t, err)
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
codersdk.ChatMessageFile(file.ID, "text/plain", "notes.txt"),
})
require.NoError(t, err)
created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: modelConfig.ID,
Title: "computer use transport",
ClientType: database.ChatClientTypeApi,
Mode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true},
InitialMessages: []chatstate.Message{
{
Role: database.ChatMessageRoleUser,
Content: content,
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ContentVersion: chatprompt.CurrentContentVersion,
},
},
})
require.NoError(t, err)
server := newInternalTestServer(
t,
db,
ps,
chatprovider.ProviderAPIKeys{},
withInternalTestServerTransportFactory(&aibridgeTestFactory{}),
)
prepared, err := server.prepareGeneration(ctx, generationPrepareInput{
Chat: created.Chat,
Messages: created.InitialMessages,
})
require.NoError(t, err)
t.Cleanup(prepared.Cleanup)
// The computer-use model (gpt-5.5) is Responses-selected by the SDK and
// its client ignores the config's forced Chat Completions, so the
// options must be the Responses type or the SDK discards them.
_, ok := prepared.ProviderOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
require.True(t, ok, "%T", prepared.ProviderOptions[fantasyopenai.Name])
// File classification must also key on the substituted model: the
// Responses transport drops native text file parts, so the attachment
// must be inlined as text rather than kept as a FilePart.
var sawInlinedText bool
for _, message := range prepared.Prompt {
for _, part := range message.Content {
if filePart, isFile := part.(fantasy.FilePart); isFile {
t.Fatalf("text attachment survived as FilePart %q", filePart.Filename)
}
if textPart, isText := part.(fantasy.TextPart); isText &&
strings.Contains(textPart.Text, attachmentText) {
sawInlinedText = true
}
}
}
require.True(t, sawInlinedText, "attachment was not inlined as text")
}
func TestPrepareGenerationSubagentUsesOwnerSyntheticAPIKey(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: org.ID,
})
provider := dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{
Type: database.AIProviderTypeOpenai,
}, "test-key")
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini",
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
}, func(p *database.InsertChatModelConfigParams) {
p.Enabled = true
})
parent := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: modelConfig.ID,
})
created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{
OrganizationID: org.ID,
OwnerID: user.ID,
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
LastModelConfigID: modelConfig.ID,
Title: "subagent attribution",
ClientType: database.ChatClientTypeApi,
InitialMessages: []chatstate.Message{
{
Role: database.ChatMessageRoleUser,
Content: mustMarshalText(t, "inspect the workspace"),
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ContentVersion: chatprompt.CurrentContentVersion,
},
},
})
require.NoError(t, err)
server := newInternalTestServer(
t,
db,
ps,
chatprovider.ProviderAPIKeys{},
withInternalTestServerTransportFactory(&aibridgeTestFactory{}),
)
prepared, err := server.prepareGeneration(ctx, generationPrepareInput{
Chat: created.Chat,
Messages: created.InitialMessages,
})
require.NoError(t, err)
t.Cleanup(prepared.Cleanup)
gatewayKey, err := db.GetChatGatewayAPIKey(ctx, database.GetChatGatewayAPIKeyParams{
UserID: user.ID,
TokenName: GatewayTokenName(user.ID),
})
require.NoError(t, err)
require.Equal(t, gatewayKey.ID, prepared.ModelBuildOptions.ActiveAPIKeyID)
}
// TestDeriveFinalTurnRunResult exercises the re-derivation path that replaces
// the old in-memory generationSideEffects stash. The server here never ran
// prepareGeneration, so a passing test proves the finish-turn inputs are
// rebuilt purely from persisted state.
func TestDeriveFinalTurnRunResult(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
setup := func(t *testing.T) (*Server, database.Chat) {
t.Helper()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: org.ID,
})
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini",
DisplayName: "gpt-4o-mini",
Options: json.RawMessage(`{"openai_config":{"use_responses_api":false}}`),
}, func(p *database.InsertChatModelConfigParams) {
p.Enabled = true
p.IsDefault = true
})
created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "derive-chat",
ClientType: database.ChatClientTypeUi,
InitialMessages: []chatstate.Message{
{
Role: database.ChatMessageRoleUser,
Content: mustMarshalText(t, "what is the answer?"),
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
},
},
})
require.NoError(t, err)
server := newInternalTestServer(
t, db, ps, chatprovider.ProviderAPIKeys{},
withInternalTestServerTransportFactory(&aibridgeTestFactory{}),
)
return server, created.Chat
}
commitAssistant := func(t *testing.T, server *Server, chat database.Chat, text string) {
t.Helper()
ctx := chatdTestContext(t)
machine := chatstate.NewChatMachine(server.db, server.pubsub, chat.ID)
require.NoError(t, machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
_, err := tx.CommitStep(chatstate.CommitStepInput{
Messages: []chatstate.Message{
{
Role: database.ChatMessageRoleAssistant,
Content: mustMarshalText(t, text),
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true},
},
},
})
return err
}))
}
t.Run("WaitingDerivesFromHistory", func(t *testing.T) {
t.Parallel()
server, chat := setup(t)
ctx := chatdTestContext(t)
commitAssistant(t, server, chat, "the answer is 42")
rows, err := server.db.GetChatMessagesForPromptByChatID(ctx, chat.ID)
require.NoError(t, err)
require.NotEmpty(t, rows)
var lastUserID int64
for _, row := range rows {
if row.Role == database.ChatMessageRoleUser {
lastUserID = row.ID
}
}
tipID := rows[len(rows)-1].ID
chat.Status = database.ChatStatusWaiting
result := server.deriveFinalTurnRunResult(ctx, chat, logger)
require.Equal(t, "the answer is 42", result.FinalAssistantText)
require.Equal(t, lastUserID, result.TriggerMessageID)
require.Equal(t, tipID, result.HistoryTipMessageID)
require.True(t, result.StatusLabelModel.Valid())
require.Equal(t, "openai", result.FallbackProvider)
require.Equal(t, "gpt-4o-mini", result.FallbackModel)
require.JSONEq(t, `{"openai_config":{"use_responses_api":false}}`, string(result.StatusLabelOptions))
})
t.Run("NonWaitingReturnsEmpty", func(t *testing.T) {
t.Parallel()
server, chat := setup(t)
ctx := chatdTestContext(t)
commitAssistant(t, server, chat, "the answer is 42")
chat.Status = database.ChatStatusError
result := server.deriveFinalTurnRunResult(ctx, chat, logger)
require.Equal(t, runChatResult{}, result)
})
t.Run("WaitingWithoutAssistantReturnsEmpty", func(t *testing.T) {
t.Parallel()
server, chat := setup(t)
ctx := chatdTestContext(t)
// No assistant message was committed, so there is nothing to label.
chat.Status = database.ChatStatusWaiting
result := server.deriveFinalTurnRunResult(ctx, chat, logger)
require.Equal(t, runChatResult{}, result)
})
t.Run("ModelResolveErrorKeepsTextAndIDs", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: org.ID,
})
// A disabled AI provider makes resolveChatModel fail, exercising the
// degraded path that still returns the re-derived text and IDs.
provider := insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "provider-api-key", false)
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini",
DisplayName: "gpt-4o-mini",
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
})
created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "derive-chat-error",
ClientType: database.ChatClientTypeUi,
InitialMessages: []chatstate.Message{
{
Role: database.ChatMessageRoleUser,
Content: mustMarshalText(t, "what is the answer?"),
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
},
},
})
require.NoError(t, err)
chat := created.Chat
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
commitAssistant(t, server, chat, "the answer is 42")
chat.Status = database.ChatStatusWaiting
result := server.deriveFinalTurnRunResult(ctx, chat, logger)
require.Equal(t, "the answer is 42", result.FinalAssistantText)
require.NotZero(t, result.TriggerMessageID)
require.NotZero(t, result.HistoryTipMessageID)
require.False(t, result.StatusLabelModel.Valid())
require.Empty(t, result.FallbackProvider)
require.Empty(t, result.FallbackModel)
})
}