feat(chatd): set User-Agent on all outgoing LLM requests (#22965)

This commit is contained in:
Atif Ali
2026-03-13 15:12:04 +05:00
committed by GitHub
parent f6f33fa480
commit 7777072d7a
5 changed files with 118 additions and 6 deletions
+1 -1
View File
@@ -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(
+18 -3
View File
@@ -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))
+19
View File
@@ -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)
}
@@ -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()")
}
+2 -2
View File
@@ -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)