mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(chatd): set User-Agent on all outgoing LLM requests (#22965)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user