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.
199 lines
6.8 KiB
Go
199 lines
6.8 KiB
Go
package chatd
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"charm.land/fantasy"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestSameCompactionProviderIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
providerID := uuid.New()
|
|
|
|
require.True(t, sameCompactionProviderIdentity(configWithProvider(providerID), configWithProvider(providerID)))
|
|
require.False(t, sameCompactionProviderIdentity(configWithProvider(providerID), configWithProvider(uuid.New())))
|
|
// Legacy configs without a provider FK compare as different (fail closed).
|
|
require.False(t, sameCompactionProviderIdentity(database.ChatModelConfig{}, configWithProvider(providerID)))
|
|
require.False(t, sameCompactionProviderIdentity(database.ChatModelConfig{}, database.ChatModelConfig{}))
|
|
}
|
|
|
|
func configWithProvider(id uuid.UUID) database.ChatModelConfig {
|
|
return database.ChatModelConfig{AIProviderID: uuid.NullUUID{UUID: id, Valid: true}}
|
|
}
|
|
|
|
func TestSanitizeCompactionPrompt_FlattensForeignProviderExecutedToolParts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
prompt := []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "search the web"},
|
|
},
|
|
},
|
|
{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "searching"},
|
|
fantasy.ToolCallPart{
|
|
ToolCallID: "ws-1",
|
|
ToolName: "web_search",
|
|
Input: `{"query":"coder"}`,
|
|
ProviderExecuted: true,
|
|
},
|
|
fantasy.ToolResultPart{
|
|
ToolCallID: "ws-1",
|
|
Output: fantasy.ToolResultOutputContentText{Text: "results"},
|
|
ProviderExecuted: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.ToolCallPart{
|
|
ToolCallID: "local-1",
|
|
ToolName: "read_file",
|
|
Input: `{"path":"/tmp/a.txt"}`,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
compactionModel := chatprovider.NewModel(&chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"}, nil)
|
|
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()))
|
|
|
|
require.Len(t, sanitized, 3)
|
|
// Provider-executed parts are flattened to text so the summary keeps
|
|
// their content without the provider-specific wire shape.
|
|
require.Len(t, sanitized[1].Content, 3)
|
|
require.Equal(t, fantasy.TextPart{Text: "searching"}, sanitized[1].Content[0])
|
|
require.Equal(t, fantasy.TextPart{Text: `[Server tool call: web_search] {"query":"coder"}`}, sanitized[1].Content[1])
|
|
require.Equal(t, fantasy.TextPart{Text: "[Server tool result: web_search] results"}, sanitized[1].Content[2])
|
|
// Local tool calls replay fine across providers and must survive.
|
|
require.Len(t, sanitized[2].Content, 1)
|
|
require.Equal(t, "read_file", sanitized[2].Content[0].(fantasy.ToolCallPart).ToolName)
|
|
|
|
// The original prompt used for assistant generation is untouched.
|
|
require.Equal(t, fantasy.ToolCallPart{
|
|
ToolCallID: "ws-1",
|
|
ToolName: "web_search",
|
|
Input: `{"query":"coder"}`,
|
|
ProviderExecuted: true,
|
|
}, prompt[1].Content[1])
|
|
}
|
|
|
|
func TestSanitizeCompactionPrompt_DropsNonAssistantProviderExecutedParts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
// Provider-executed parts outside assistant messages are anomalous; a
|
|
// flattened text part is not valid tool-message content, so they drop.
|
|
prompt := []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleTool,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.ToolResultPart{
|
|
ToolCallID: "ws-1",
|
|
Output: fantasy.ToolResultOutputContentText{Text: "results"},
|
|
ProviderExecuted: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "hello"},
|
|
},
|
|
},
|
|
}
|
|
|
|
compactionModel := chatprovider.NewModel(&chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"}, nil)
|
|
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()))
|
|
|
|
require.Len(t, sanitized, 1)
|
|
require.Equal(t, fantasy.MessageRoleUser, sanitized[0].Role)
|
|
require.Len(t, prompt, 2)
|
|
}
|
|
|
|
func TestSanitizeCompactionPrompt_ReplacesUnsupportedFileParts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
prompt := []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "look at this"},
|
|
fantasy.FilePart{
|
|
Filename: "diagram.pdf",
|
|
Data: []byte("%PDF-"),
|
|
MediaType: "application/pdf",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Mistral accepts images but not PDFs, so the PDF part must become a
|
|
// placeholder while the prompt stays otherwise intact.
|
|
compactionModel := chatprovider.NewModel(&chattest.FakeModel{ProviderName: "mistral", ModelName: "mistral-large"}, nil)
|
|
sharedProviderID := uuid.New()
|
|
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID))
|
|
|
|
require.Len(t, sanitized, 1)
|
|
require.Len(t, sanitized[0].Content, 2)
|
|
textPart, ok := sanitized[0].Content[1].(fantasy.TextPart)
|
|
require.True(t, ok)
|
|
require.Contains(t, textPart.Text, "diagram.pdf")
|
|
require.Contains(t, textPart.Text, "not supported by the compaction model")
|
|
|
|
// The original prompt keeps its file part.
|
|
_, ok = prompt[0].Content[1].(fantasy.FilePart)
|
|
require.True(t, ok)
|
|
}
|
|
|
|
func TestSanitizeCompactionPrompt_SameProviderKeepsProviderExecutedParts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
prompt := []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.ToolCallPart{
|
|
ToolCallID: "ws-1",
|
|
ToolName: "web_search",
|
|
Input: `{"query":"coder"}`,
|
|
ProviderExecuted: true,
|
|
},
|
|
fantasy.ToolResultPart{
|
|
ToolCallID: "ws-1",
|
|
Output: fantasy.ToolResultOutputContentText{Text: "results"},
|
|
ProviderExecuted: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
compactionModel := chatprovider.NewModel(&chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"}, nil)
|
|
sharedProviderID := uuid.New()
|
|
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID))
|
|
|
|
require.Len(t, sanitized, 1)
|
|
require.Len(t, sanitized[0].Content, 2)
|
|
}
|