mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
`coder exp scaletest chat` now bootstraps its mock LLM using the, post-gateway unification, AI provider API instead of the removed experimental chat-provider API, and creates or reuses a chat model config linked to that provider. When the mock provider is created or updated, the command waits a flat, hidden `--provider-propagation-wait` (default 15s) before starting the scale run, since each coderd replica caches provider config with a 10s TTL and only expiry guarantees every replica sees the change. The command also runs without any scaletest workspaces, creating chats with no workspace context. The integration test covers the CLI path against `llmmock` with a near-zero propagation wait, verifies the provider/model config setup, and asserts the generated chat records user and assistant messages. Relates to CODAGT-307 Relates to GRU-48
142 lines
4.3 KiB
Go
142 lines
4.3 KiB
Go
//go:build !slim
|
|
|
|
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/sloghuman"
|
|
"github.com/coder/coder/v2/cli/clitest"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/scaletest/llmmock"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
const scaletestChatPrompt = "Reply with one short sentence from the scaletest."
|
|
|
|
func TestScaleTestChat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
values := coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) {
|
|
require.NoError(t, dv.AI.BridgeConfig.Enabled.Set("true"))
|
|
// Keep AI Gateway routing disabled so the chat uses the direct model
|
|
// route to the mock provider, avoiding the need for an aibridged daemon.
|
|
require.NoError(t, dv.AI.Chat.AIGatewayRoutingEnabled.Set("false"))
|
|
})
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: values,
|
|
})
|
|
coderdtest.CreateFirstUser(t, client)
|
|
|
|
server := new(llmmock.Server)
|
|
require.NoError(t, server.Start(context.Background(), llmmock.Config{
|
|
Address: "127.0.0.1:0",
|
|
Logger: slog.Make(sloghuman.Sink(io.Discard)).Leveled(slog.LevelDebug),
|
|
}))
|
|
t.Cleanup(func() {
|
|
require.NoError(t, server.Stop())
|
|
})
|
|
mockURL := server.APIAddress() + "/v1"
|
|
|
|
inv, root := clitest.New(t,
|
|
"exp", "scaletest", "chat",
|
|
"--chats-per-workspace", "1",
|
|
"--turns", "1",
|
|
"--prompt", scaletestChatPrompt,
|
|
"--timeout", "30s",
|
|
"--job-timeout", "30s",
|
|
"--cleanup-timeout", "30s",
|
|
"--cleanup-job-timeout", "30s",
|
|
"--scaletest-prometheus-address", "127.0.0.1:0",
|
|
"--scaletest-prometheus-wait", "0s",
|
|
"--provider-propagation-wait", "10ms",
|
|
"--llm-mock-url", mockURL,
|
|
)
|
|
//nolint:gocritic // The scaletest chat command requires an admin client.
|
|
clitest.SetupConfig(t, client, root)
|
|
|
|
var stderr bytes.Buffer
|
|
inv.Stdout = io.Discard
|
|
inv.Stderr = &stderr
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err, stderr.String())
|
|
require.Contains(t, stderr.String(), "Scale test passed: 1/1 runs succeeded")
|
|
|
|
provider, err := client.AIProvider(ctx, "coder-scaletest-mock")
|
|
require.NoError(t, err)
|
|
require.Equal(t, mockURL, provider.BaseURL)
|
|
|
|
expClient := codersdk.NewExperimentalClient(client)
|
|
configs, err := expClient.ListChatModelConfigs(ctx)
|
|
require.NoError(t, err)
|
|
matchingConfigs := scaletestModelConfigsForProvider(configs, provider.ID)
|
|
require.Len(t, matchingConfigs, 1)
|
|
require.True(t, matchingConfigs[0].Enabled)
|
|
|
|
chats, err := expClient.ListChats(ctx, &codersdk.ListChatsOptions{Query: "archived:true"})
|
|
require.NoError(t, err)
|
|
|
|
var scaletestMessages []codersdk.ChatMessage
|
|
for _, chat := range chats {
|
|
resp, err := expClient.GetChatMessages(ctx, chat.ID, nil)
|
|
require.NoError(t, err)
|
|
if userText, ok := chatMessageText(resp.Messages, codersdk.ChatMessageRoleUser); ok &&
|
|
strings.Contains(userText, scaletestChatPrompt) {
|
|
scaletestMessages = resp.Messages
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, scaletestMessages)
|
|
assistantText, ok := chatMessageText(scaletestMessages, codersdk.ChatMessageRoleAssistant)
|
|
require.True(t, ok, "expected an assistant reply in the scaletest chat")
|
|
require.NotEmpty(t, assistantText)
|
|
}
|
|
|
|
// chatMessageText concatenates the text parts of every message with the given
|
|
// role, reporting whether any such message was found. It aggregates across
|
|
// messages because the API returns them newest-first and a turn can produce
|
|
// more than one message per role.
|
|
func chatMessageText(messages []codersdk.ChatMessage, role codersdk.ChatMessageRole) (string, bool) {
|
|
var (
|
|
b strings.Builder
|
|
found bool
|
|
)
|
|
for _, msg := range messages {
|
|
if msg.Role != role {
|
|
continue
|
|
}
|
|
found = true
|
|
for _, part := range msg.Content {
|
|
if part.Type == codersdk.ChatMessagePartTypeText {
|
|
_, _ = b.WriteString(part.Text)
|
|
}
|
|
}
|
|
}
|
|
return b.String(), found
|
|
}
|
|
|
|
func scaletestModelConfigsForProvider(configs []codersdk.ChatModelConfig, providerID uuid.UUID) []codersdk.ChatModelConfig {
|
|
matches := make([]codersdk.ChatModelConfig, 0, 1)
|
|
for _, config := range configs {
|
|
if config.AIProviderID == nil || *config.AIProviderID != providerID {
|
|
continue
|
|
}
|
|
if config.Model != "scaletest-model" {
|
|
continue
|
|
}
|
|
matches = append(matches, config)
|
|
}
|
|
return matches
|
|
}
|