diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index 3dcbb248c9..267d9b2130 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -2852,7 +2852,7 @@ func (p *Server) resolveChatModel( ) model, err := chatprovider.ModelFromConfig( - dbConfig.Provider, dbConfig.Model, keys, + dbConfig.Provider, dbConfig.Model, keys, chatprovider.UserAgent(), ) if err != nil { return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, xerrors.Errorf( diff --git a/coderd/chatd/chatprovider/chatprovider.go b/coderd/chatd/chatprovider/chatprovider.go index 4a7bb51443..26447cefde 100644 --- a/coderd/chatd/chatprovider/chatprovider.go +++ b/coderd/chatd/chatprovider/chatprovider.go @@ -916,11 +916,14 @@ func MergeMissingProviderOptions( } // ModelFromConfig resolves a provider/model pair and constructs a fantasy -// language model client using the provided provider credentials. +// language model client using the provided provider credentials. The +// userAgent is sent as the User-Agent header on every outgoing LLM +// API request. func ModelFromConfig( providerHint string, modelName string, providerKeys ProviderAPIKeys, + userAgent string, ) (fantasy.LanguageModel, error) { provider, modelID, err := ResolveModelWithProviderHint(modelName, providerHint) if err != nil { @@ -938,6 +941,7 @@ func ModelFromConfig( case fantasyanthropic.Name: options := []fantasyanthropic.Option{ fantasyanthropic.WithAPIKey(apiKey), + fantasyanthropic.WithUserAgent(userAgent), } if baseURL != "" { options = append(options, fantasyanthropic.WithBaseURL(baseURL)) @@ -951,12 +955,17 @@ func ModelFromConfig( fantasyazure.WithAPIKey(apiKey), fantasyazure.WithBaseURL(baseURL), fantasyazure.WithUseResponsesAPI(), + fantasyazure.WithUserAgent(userAgent), ) case fantasybedrock.Name: - providerClient, err = fantasybedrock.New(fantasybedrock.WithAPIKey(apiKey)) + providerClient, err = fantasybedrock.New( + fantasybedrock.WithAPIKey(apiKey), + fantasybedrock.WithUserAgent(userAgent), + ) case fantasygoogle.Name: options := []fantasygoogle.Option{ fantasygoogle.WithGeminiAPIKey(apiKey), + fantasygoogle.WithUserAgent(userAgent), } if baseURL != "" { options = append(options, fantasygoogle.WithBaseURL(baseURL)) @@ -966,6 +975,7 @@ func ModelFromConfig( options := []fantasyopenai.Option{ fantasyopenai.WithAPIKey(apiKey), fantasyopenai.WithUseResponsesAPI(), + fantasyopenai.WithUserAgent(userAgent), } if baseURL != "" { options = append(options, fantasyopenai.WithBaseURL(baseURL)) @@ -974,16 +984,21 @@ func ModelFromConfig( case fantasyopenaicompat.Name: options := []fantasyopenaicompat.Option{ fantasyopenaicompat.WithAPIKey(apiKey), + fantasyopenaicompat.WithUserAgent(userAgent), } if baseURL != "" { options = append(options, fantasyopenaicompat.WithBaseURL(baseURL)) } providerClient, err = fantasyopenaicompat.New(options...) case fantasyopenrouter.Name: - providerClient, err = fantasyopenrouter.New(fantasyopenrouter.WithAPIKey(apiKey)) + providerClient, err = fantasyopenrouter.New( + fantasyopenrouter.WithAPIKey(apiKey), + fantasyopenrouter.WithUserAgent(userAgent), + ) case fantasyvercel.Name: options := []fantasyvercel.Option{ fantasyvercel.WithAPIKey(apiKey), + fantasyvercel.WithUserAgent(userAgent), } if baseURL != "" { options = append(options, fantasyvercel.WithBaseURL(baseURL)) diff --git a/coderd/chatd/chatprovider/useragent.go b/coderd/chatd/chatprovider/useragent.go new file mode 100644 index 0000000000..9c8ba05c17 --- /dev/null +++ b/coderd/chatd/chatprovider/useragent.go @@ -0,0 +1,19 @@ +package chatprovider + +import ( + "fmt" + "runtime" + + "github.com/coder/coder/v2/buildinfo" +) + +// UserAgent returns the User-Agent string sent on all outgoing LLM +// API requests made by Coder's built-in chat (chatd). The format +// mirrors conventions used by other coding agents so that LLM +// providers can identify traffic originating from Coder. +// +// Example: coder-agents/v2.21.0 (linux/amd64) +func UserAgent() string { + return fmt.Sprintf("coder-agents/%s (%s/%s)", + buildinfo.Version(), runtime.GOOS, runtime.GOARCH) +} diff --git a/coderd/chatd/chatprovider/useragent_test.go b/coderd/chatd/chatprovider/useragent_test.go new file mode 100644 index 0000000000..2e2c482118 --- /dev/null +++ b/coderd/chatd/chatprovider/useragent_test.go @@ -0,0 +1,78 @@ +package chatprovider_test + +import ( + "context" + "runtime" + "strings" + "sync" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/chatd/chatprovider" + "github.com/coder/coder/v2/coderd/chatd/chattest" +) + +func TestUserAgent(t *testing.T) { + t.Parallel() + ua := chatprovider.UserAgent() + + // Must start with "coder-agents/" so LLM providers can + // identify traffic from Coder. + require.True(t, strings.HasPrefix(ua, "coder-agents/"), + "User-Agent should start with 'coder-agents/', got %q", ua) + + // Must contain the build version. + assert.Contains(t, ua, buildinfo.Version()) + + // Must contain OS/arch. + assert.Contains(t, ua, runtime.GOOS+"/"+runtime.GOARCH) +} + +func TestModelFromConfig_UserAgent(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var capturedUA string + + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + mu.Lock() + capturedUA = req.Header.Get("User-Agent") + mu.Unlock() + return chattest.OpenAINonStreamingResponse("hello") + }) + + expectedUA := chatprovider.UserAgent() + keys := chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{"openai": "test-key"}, + BaseURLByProvider: map[string]string{"openai": serverURL}, + } + + model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, expectedUA) + require.NoError(t, err) + + // Make a real call so Fantasy sends an HTTP request to the + // fake server, which captures the User-Agent header. + _, err = model.Generate(context.Background(), fantasy.Call{ + Prompt: []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hello"}, + }, + }, + }, + }) + require.NoError(t, err) + + mu.Lock() + got := capturedUA + mu.Unlock() + + require.NotEmpty(t, got, "User-Agent header was not sent") + require.Equal(t, expectedUA, got, + "User-Agent header should match chatprovider.UserAgent()") +} diff --git a/coderd/chatd/quickgen.go b/coderd/chatd/quickgen.go index 922100a7fc..321796208f 100644 --- a/coderd/chatd/quickgen.go +++ b/coderd/chatd/quickgen.go @@ -76,7 +76,7 @@ func (p *Server) maybeGenerateChatTitle( candidates := make([]fantasy.LanguageModel, 0, len(preferredTitleModels)+1) for _, c := range preferredTitleModels { m, err := chatprovider.ModelFromConfig( - c.provider, c.model, keys, + c.provider, c.model, keys, chatprovider.UserAgent(), ) if err == nil { candidates = append(candidates, m) @@ -281,7 +281,7 @@ func generatePushSummary( candidates := make([]fantasy.LanguageModel, 0, len(preferredTitleModels)+1) for _, c := range preferredTitleModels { m, err := chatprovider.ModelFromConfig( - c.provider, c.model, keys, + c.provider, c.model, keys, chatprovider.UserAgent(), ) if err == nil { candidates = append(candidates, m)