From 9e76f151b9e98c51aa5f70d3891beb6cee62d18a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 10:34:38 -0500 Subject: [PATCH] feat(useragent): configuratble provider-level user agent Note that the user agent defaults to Charm Fantasy/, which means we need to maintain a const. --- providers/anthropic/anthropic.go | 34 +++- providers/anthropic/useragent_test.go | 120 +++++++++++ providers/google/google.go | 50 +++-- providers/google/useragent_test.go | 187 ++++++++++++++++++ providers/internal/httpheaders/httpheaders.go | 71 +++++++ .../internal/httpheaders/httpheaders_test.go | 141 +++++++++++++ providers/openai/openai.go | 24 ++- providers/openai/openai_test.go | 95 +++++++++ version.go | 4 + 9 files changed, 708 insertions(+), 18 deletions(-) create mode 100644 providers/anthropic/useragent_test.go create mode 100644 providers/google/useragent_test.go create mode 100644 providers/internal/httpheaders/httpheaders.go create mode 100644 providers/internal/httpheaders/httpheaders_test.go create mode 100644 version.go diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 4b593408dda2b4ebb5cf81e30df6d910fb311ccd..13a2a06d7c8701dd089e154b160cb0a5e5d9a2b6 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -14,6 +14,7 @@ import ( "charm.land/fantasy" "charm.land/fantasy/object" + "charm.land/fantasy/providers/internal/httpheaders" "github.com/aws/aws-sdk-go-v2/config" "github.com/charmbracelet/anthropic-sdk-go" "github.com/charmbracelet/anthropic-sdk-go/bedrock" @@ -31,11 +32,13 @@ const ( ) type options struct { - baseURL string - apiKey string - name string - headers map[string]string - client option.HTTPClient + baseURL string + apiKey string + name string + headers map[string]string + userAgent string + agentSegment string + client option.HTTPClient vertexProject string vertexLocation string @@ -125,6 +128,23 @@ func WithHTTPClient(client option.HTTPClient) Option { } } +// WithUserAgent sets an explicit User-Agent header, overriding the default and any +// value set via WithHeaders. +func WithUserAgent(ua string) Option { + return func(o *options) { + o.userAgent = ua + } +} + +// WithAgentSegment sets the agent segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithAgentSegment(agent string) Option { + return func(o *options) { + o.agentSegment = agent + } +} + // WithObjectMode sets the object generation mode. func WithObjectMode(om fantasy.ObjectMode) Option { return func(o *options) { @@ -146,7 +166,9 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L if a.options.baseURL != "" { clientOptions = append(clientOptions, option.WithBaseURL(a.options.baseURL)) } - for key, value := range a.options.headers { + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.agentSegment) + resolved := httpheaders.ResolveHeaders(a.options.headers, a.options.userAgent, defaultUA) + for key, value := range resolved { clientOptions = append(clientOptions, option.WithHeader(key, value)) } if a.options.client != nil { diff --git a/providers/anthropic/useragent_test.go b/providers/anthropic/useragent_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ec705cc49992c0225320690cac1ad7e78cb834d8 --- /dev/null +++ b/providers/anthropic/useragent_test.go @@ -0,0 +1,120 @@ +package anthropic + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserAgent(t *testing.T) { + t.Parallel() + + newUAServer := func() (*httptest.Server, *[]map[string]string) { + var captured []map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := make(map[string]string) + for k, v := range r.Header { + if len(v) > 0 { + h[k] = v[0] + } + } + captured = append(captured, h) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(mockAnthropicGenerateResponse()) + })) + return server, &captured + } + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "Hi"}}, + }, + } + + t.Run("default UA applied", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New(WithAPIKey("k"), WithBaseURL(server.URL)) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "claude-sonnet-4-20250514") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"]) + }) + + t.Run("agent segment format", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New(WithAPIKey("k"), WithBaseURL(server.URL), WithAgentSegment("Claude 4.6 Opus")) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "claude-sonnet-4-20250514") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version+" (Claude 4.6 Opus)", (*captured)[0]["User-Agent"]) + }) + + t.Run("WithHeaders User-Agent wins over default", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New(WithAPIKey("k"), WithBaseURL(server.URL), WithHeaders(map[string]string{"User-Agent": "custom-from-headers"})) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "claude-sonnet-4-20250514") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "custom-from-headers", (*captured)[0]["User-Agent"]) + }) + + t.Run("WithUserAgent wins over both", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithAPIKey("k"), + WithBaseURL(server.URL), + WithHeaders(map[string]string{"User-Agent": "from-headers"}), + WithUserAgent("explicit-ua"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "claude-sonnet-4-20250514") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) + }) + + t.Run("WithAgentSegment empty clears segment", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithAPIKey("k"), + WithBaseURL(server.URL), + WithAgentSegment("initial"), + WithAgentSegment(""), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "claude-sonnet-4-20250514") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"]) + }) +} diff --git a/providers/google/google.go b/providers/google/google.go index eedc4237bd710f65daefa95d3ba4f05286f2a154..c11d988d3c7b6de9e61c6862eb747b2a2bcf856b 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -14,6 +14,7 @@ import ( "charm.land/fantasy" "charm.land/fantasy/object" "charm.land/fantasy/providers/anthropic" + "charm.land/fantasy/providers/internal/httpheaders" "charm.land/fantasy/schema" "cloud.google.com/go/auth" "github.com/charmbracelet/x/exp/slice" @@ -36,6 +37,8 @@ type options struct { name string baseURL string headers map[string]string + userAgent string + agentSegment string client *http.Client backend genai.Backend project string @@ -132,6 +135,23 @@ func WithToolCallIDFunc(f ToolCallIDFunc) Option { } } +// WithUserAgent sets an explicit User-Agent header, overriding the default and any +// value set via WithHeaders. +func WithUserAgent(ua string) Option { + return func(o *options) { + o.userAgent = ua + } +} + +// WithAgentSegment sets the agent segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithAgentSegment(agent string) Option { + return func(o *options) { + o.agentSegment = agent + } +} + // WithObjectMode sets the object generation mode for the Google provider. func WithObjectMode(om fantasy.ObjectMode) Option { return func(o *options) { @@ -154,11 +174,18 @@ type languageModel struct { // LanguageModel implements fantasy.Provider. func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.LanguageModel, error) { if strings.Contains(modelID, "anthropic") || strings.Contains(modelID, "claude") { - p, err := anthropic.New( + anthropicOpts := []anthropic.Option{ anthropic.WithVertex(a.options.project, a.options.location), anthropic.WithHTTPClient(a.options.client), anthropic.WithSkipAuth(a.options.skipAuth), - ) + } + if a.options.userAgent != "" { + anthropicOpts = append(anthropicOpts, anthropic.WithUserAgent(a.options.userAgent)) + } + if a.options.agentSegment != "" { + anthropicOpts = append(anthropicOpts, anthropic.WithAgentSegment(a.options.agentSegment)) + } + p, err := anthropic.New(anthropicOpts...) if err != nil { return nil, err } @@ -180,15 +207,16 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L } } - if a.options.baseURL != "" || len(a.options.headers) > 0 { - headers := http.Header{} - for k, v := range a.options.headers { - headers.Add(k, v) - } - cc.HTTPOptions = genai.HTTPOptions{ - BaseURL: a.options.baseURL, - Headers: headers, - } + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.agentSegment) + resolved := httpheaders.ResolveHeaders(a.options.headers, a.options.userAgent, defaultUA) + + headers := http.Header{} + for k, v := range resolved { + headers.Set(k, v) + } + cc.HTTPOptions = genai.HTTPOptions{ + BaseURL: a.options.baseURL, + Headers: headers, } client, err := genai.NewClient(ctx, cc) if err != nil { diff --git a/providers/google/useragent_test.go b/providers/google/useragent_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e4ec3b83d855b7b62f7f066ca87e3afdbb1ddc10 --- /dev/null +++ b/providers/google/useragent_test.go @@ -0,0 +1,187 @@ +package google + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserAgent(t *testing.T) { + t.Parallel() + + newUAServer := func() (*httptest.Server, *[]map[string]string) { + var captured []map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := make(map[string]string) + for k, v := range r.Header { + if len(v) > 0 { + h[k] = v[0] + } + } + captured = append(captured, h) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{ + { + "content": map[string]any{ + "role": "model", + "parts": []map[string]any{ + {"text": "Hello"}, + }, + }, + "finishReason": "STOP", + }, + }, + "usageMetadata": map[string]any{ + "promptTokenCount": 5, + "candidatesTokenCount": 2, + "totalTokenCount": 7, + }, + }) + })) + return server, &captured + } + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "Hi"}}, + }, + } + + findUA := func(captured *[]map[string]string, want string) bool { + for _, h := range *captured { + if ua, ok := h["User-Agent"]; ok && ua == want { + return true + } + } + return false + } + + t.Run("default UA applied", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithVertex("test-project", "us-central1"), + WithBaseURL(server.URL), + WithSkipAuth(true), + ) + require.NoError(t, err) + model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") + require.NoError(t, err) + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.NotEmpty(t, *captured) + assert.True(t, findUA(captured, "Charm Fantasy/"+fantasy.Version)) + }) + + t.Run("agent segment format", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithVertex("test-project", "us-central1"), + WithBaseURL(server.URL), + WithSkipAuth(true), + WithAgentSegment("Claude 4.6 Opus"), + ) + require.NoError(t, err) + model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") + require.NoError(t, err) + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.NotEmpty(t, *captured) + assert.True(t, findUA(captured, "Charm Fantasy/"+fantasy.Version+" (Claude 4.6 Opus)")) + }) + + t.Run("WithUserAgent wins over default", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithVertex("test-project", "us-central1"), + WithBaseURL(server.URL), + WithSkipAuth(true), + WithUserAgent("explicit-ua"), + ) + require.NoError(t, err) + model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") + require.NoError(t, err) + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.NotEmpty(t, *captured) + assert.True(t, findUA(captured, "explicit-ua")) + }) + + t.Run("WithHeaders User-Agent wins over default", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithVertex("test-project", "us-central1"), + WithBaseURL(server.URL), + WithSkipAuth(true), + WithHeaders(map[string]string{"User-Agent": "custom-from-headers"}), + ) + require.NoError(t, err) + model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") + require.NoError(t, err) + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.NotEmpty(t, *captured) + assert.True(t, findUA(captured, "custom-from-headers")) + }) + + t.Run("WithUserAgent wins over WithHeaders", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithVertex("test-project", "us-central1"), + WithBaseURL(server.URL), + WithSkipAuth(true), + WithHeaders(map[string]string{"User-Agent": "from-headers"}), + WithUserAgent("explicit-ua"), + ) + require.NoError(t, err) + model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") + require.NoError(t, err) + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.NotEmpty(t, *captured) + assert.True(t, findUA(captured, "explicit-ua")) + }) + + t.Run("WithAgentSegment empty clears segment", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithVertex("test-project", "us-central1"), + WithBaseURL(server.URL), + WithSkipAuth(true), + WithAgentSegment("initial"), + WithAgentSegment(""), + ) + require.NoError(t, err) + model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") + require.NoError(t, err) + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.NotEmpty(t, *captured) + assert.True(t, findUA(captured, "Charm Fantasy/"+fantasy.Version)) + }) +} diff --git a/providers/internal/httpheaders/httpheaders.go b/providers/internal/httpheaders/httpheaders.go new file mode 100644 index 0000000000000000000000000000000000000000..167cb0fbbf6cdfd402724a255c85650dded23c6d --- /dev/null +++ b/providers/internal/httpheaders/httpheaders.go @@ -0,0 +1,71 @@ +// Package httpheaders provides shared User-Agent resolution for all HTTP-based providers. +package httpheaders + +import ( + "strings" + "unicode" +) + +const maxAgentLength = 64 + +// DefaultUserAgent returns the default User-Agent string for the SDK. +// If agent is non-empty, the result is "Charm Fantasy/ ()". +// Otherwise, the result is "Charm Fantasy/". +func DefaultUserAgent(version, agent string) string { + const sdk = "Charm Fantasy/" + agent = sanitizeAgent(agent) + if agent == "" { + return sdk + version + } + return sdk + version + " (" + agent + ")" +} + +// ResolveHeaders returns a new header map with User-Agent resolved according to precedence: +// 1. explicitUA (highest — set via WithUserAgent) +// 2. existing User-Agent key in headers (case-insensitive — set via WithHeaders) +// 3. defaultUA (lowest — generated default) +// +// The input map is never mutated. +func ResolveHeaders(headers map[string]string, explicitUA, defaultUA string) map[string]string { + out := make(map[string]string, len(headers)+1) + var uaKeys []string + + for k, v := range headers { + out[k] = v + if strings.EqualFold(k, "User-Agent") { + uaKeys = append(uaKeys, k) + } + } + + switch { + case explicitUA != "": + for _, k := range uaKeys { + delete(out, k) + } + out["User-Agent"] = explicitUA + case len(uaKeys) > 0: + // keep the header-map value as-is + default: + out["User-Agent"] = defaultUA + } + + return out +} + +func sanitizeAgent(s string) string { + s = strings.TrimSpace(s) + var b strings.Builder + b.Grow(len(s)) + count := 0 + for _, r := range s { + if r < 0x20 || r == '(' || r == ')' { + continue + } + if count >= maxAgentLength { + break + } + b.WriteRune(r) + count++ + } + return strings.TrimRightFunc(b.String(), unicode.IsSpace) +} diff --git a/providers/internal/httpheaders/httpheaders_test.go b/providers/internal/httpheaders/httpheaders_test.go new file mode 100644 index 0000000000000000000000000000000000000000..771af687671aa519ccd832cdac3f1881b57d832f --- /dev/null +++ b/providers/internal/httpheaders/httpheaders_test.go @@ -0,0 +1,141 @@ +package httpheaders + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultUserAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + agent string + want string + }{ + {name: "no agent", version: "0.11.0", agent: "", want: "Charm Fantasy/0.11.0"}, + {name: "with agent", version: "0.11.0", agent: "Claude 4.6 Opus", want: "Charm Fantasy/0.11.0 (Claude 4.6 Opus)"}, + {name: "agent trimmed", version: "1.0.0", agent: " spaces ", want: "Charm Fantasy/1.0.0 (spaces)"}, + {name: "agent strips parens", version: "1.0.0", agent: "foo(bar)", want: "Charm Fantasy/1.0.0 (foobar)"}, + {name: "agent strips control chars", version: "1.0.0", agent: "foo\x01bar", want: "Charm Fantasy/1.0.0 (foobar)"}, + {name: "agent capped at 64 chars", version: "1.0.0", agent: strings.Repeat("a", 100), want: "Charm Fantasy/1.0.0 (" + strings.Repeat("a", 64) + ")"}, + {name: "whitespace-only agent treated as empty", version: "1.0.0", agent: " ", want: "Charm Fantasy/1.0.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := DefaultUserAgent(tt.version, tt.agent) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveHeaders_Precedence(t *testing.T) { + t.Parallel() + + t.Run("explicit UA wins over headers and default", func(t *testing.T) { + t.Parallel() + headers := map[string]string{"User-Agent": "from-headers"} + got := ResolveHeaders(headers, "explicit-ua", "default-ua") + assert.Equal(t, "explicit-ua", got["User-Agent"]) + }) + + t.Run("header UA wins over default", func(t *testing.T) { + t.Parallel() + headers := map[string]string{"User-Agent": "from-headers"} + got := ResolveHeaders(headers, "", "default-ua") + assert.Equal(t, "from-headers", got["User-Agent"]) + }) + + t.Run("default UA used when nothing else set", func(t *testing.T) { + t.Parallel() + got := ResolveHeaders(nil, "", "default-ua") + assert.Equal(t, "default-ua", got["User-Agent"]) + }) + + t.Run("explicit UA wins over case-insensitive header key", func(t *testing.T) { + t.Parallel() + headers := map[string]string{"user-agent": "from-headers"} + got := ResolveHeaders(headers, "explicit-ua", "default-ua") + assert.Equal(t, "explicit-ua", got["User-Agent"]) + _, hasLower := got["user-agent"] + assert.False(t, hasLower, "old case-insensitive key should be removed") + }) + + t.Run("case-insensitive header key preserved when no explicit UA", func(t *testing.T) { + t.Parallel() + headers := map[string]string{"user-agent": "from-headers"} + got := ResolveHeaders(headers, "", "default-ua") + assert.Equal(t, "from-headers", got["user-agent"]) + }) +} + +func TestResolveHeaders_NoMutation(t *testing.T) { + t.Parallel() + + original := map[string]string{"X-Custom": "value"} + _ = ResolveHeaders(original, "explicit", "default") + + _, hasUA := original["User-Agent"] + require.False(t, hasUA, "input map must not be mutated") + assert.Equal(t, "value", original["X-Custom"]) +} + +func TestResolveHeaders_PreservesOtherHeaders(t *testing.T) { + t.Parallel() + + headers := map[string]string{ + "X-Custom": "custom-value", + "Authorization": "Bearer token", + } + got := ResolveHeaders(headers, "", "Charm Fantasy/1.0.0") + assert.Equal(t, "custom-value", got["X-Custom"]) + assert.Equal(t, "Bearer token", got["Authorization"]) + assert.Equal(t, "Charm Fantasy/1.0.0", got["User-Agent"]) +} + +func TestSanitizeAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "normal text", input: "Claude 4.6 Opus", want: "Claude 4.6 Opus"}, + {name: "leading trailing spaces", input: " spaced ", want: "spaced"}, + {name: "parentheses removed", input: "agent(v2)", want: "agentv2"}, + {name: "control chars removed", input: "a\x00b\x1fc", want: "abc"}, + {name: "capped at 64", input: strings.Repeat("x", 100), want: strings.Repeat("x", 64)}, + {name: "multibyte runes capped at 64 chars", input: strings.Repeat("é", 100), want: strings.Repeat("é", 64)}, + {name: "empty stays empty", input: "", want: ""}, + {name: "only spaces", input: " ", want: ""}, + {name: "trailing space after cap", input: strings.Repeat("a", 63) + " b", want: strings.Repeat("a", 63)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := sanitizeAgent(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveHeaders_DuplicateCaseInsensitiveKeys(t *testing.T) { + t.Parallel() + + headers := map[string]string{ + "User-Agent": "canonical", + "user-agent": "lowercase", + } + got := ResolveHeaders(headers, "explicit", "default") + assert.Equal(t, "explicit", got["User-Agent"]) + _, hasLower := got["user-agent"] + assert.False(t, hasLower, "all case-insensitive UA keys must be removed") +} diff --git a/providers/openai/openai.go b/providers/openai/openai.go index 7ca74b9c78f3d0be5955ae941b645875716da01e..1e289ad0f30227223b9cd02fc0bdbc8abd06f424 100644 --- a/providers/openai/openai.go +++ b/providers/openai/openai.go @@ -7,6 +7,7 @@ import ( "maps" "charm.land/fantasy" + "charm.land/fantasy/providers/internal/httpheaders" "github.com/openai/openai-go/v2" "github.com/openai/openai-go/v2/option" ) @@ -30,6 +31,8 @@ type options struct { name string useResponsesAPI bool headers map[string]string + userAgent string + agentSegment string client option.HTTPClient sdkOptions []option.RequestOption objectMode fantasy.ObjectMode @@ -132,6 +135,23 @@ func WithUseResponsesAPI() Option { } } +// WithUserAgent sets an explicit User-Agent header, overriding the default and any +// value set via WithHeaders. +func WithUserAgent(ua string) Option { + return func(o *options) { + o.userAgent = ua + } +} + +// WithAgentSegment sets the agent segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithAgentSegment(agent string) Option { + return func(o *options) { + o.agentSegment = agent + } +} + // WithObjectMode sets the object generation mode. func WithObjectMode(om fantasy.ObjectMode) Option { return func(o *options) { @@ -155,7 +175,9 @@ func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.Lan openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(o.options.baseURL)) } - for key, value := range o.options.headers { + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, o.options.agentSegment) + resolved := httpheaders.ResolveHeaders(o.options.headers, o.options.userAgent, defaultUA) + for key, value := range resolved { openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value)) } diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 7592b18b2c8c361f7377ba277bf214621c77fbff..d696faf01bcdbae2d3f722b23593d2089d826cab 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -12,6 +12,7 @@ import ( "charm.land/fantasy" "github.com/openai/openai-go/v2/packages/param" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -3302,3 +3303,97 @@ func TestParseContextTooLargeError(t *testing.T) { }) } } + +func TestUserAgent(t *testing.T) { + t.Parallel() + + t.Run("default UA applied", func(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.prepareJSONResponse(map[string]any{}) + + p, err := New(WithAPIKey("k"), WithBaseURL(server.server.URL)) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: testPrompt}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version, server.calls[0].headers["User-Agent"]) + }) + + t.Run("agent segment format", func(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.prepareJSONResponse(map[string]any{}) + + p, err := New(WithAPIKey("k"), WithBaseURL(server.server.URL), WithAgentSegment("Claude 4.6 Opus")) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: testPrompt}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version+" (Claude 4.6 Opus)", server.calls[0].headers["User-Agent"]) + }) + + t.Run("WithHeaders User-Agent wins over default", func(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.prepareJSONResponse(map[string]any{}) + + p, err := New(WithAPIKey("k"), WithBaseURL(server.server.URL), WithHeaders(map[string]string{"User-Agent": "custom-from-headers"})) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: testPrompt}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "custom-from-headers", server.calls[0].headers["User-Agent"]) + }) + + t.Run("WithUserAgent wins over both", func(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.prepareJSONResponse(map[string]any{}) + + p, err := New( + WithAPIKey("k"), + WithBaseURL(server.server.URL), + WithHeaders(map[string]string{"User-Agent": "from-headers"}), + WithUserAgent("explicit-ua"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: testPrompt}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "explicit-ua", server.calls[0].headers["User-Agent"]) + }) + + t.Run("WithAgentSegment empty clears segment", func(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.prepareJSONResponse(map[string]any{}) + + p, err := New( + WithAPIKey("k"), + WithBaseURL(server.server.URL), + WithAgentSegment("initial"), + WithAgentSegment(""), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: testPrompt}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version, server.calls[0].headers["User-Agent"]) + }) +} diff --git a/version.go b/version.go new file mode 100644 index 0000000000000000000000000000000000000000..ff6fd6a5ab206c025a14224a9a7d82d9fbbb09f3 --- /dev/null +++ b/version.go @@ -0,0 +1,4 @@ +package fantasy + +// Version is the SDK version. Update this before tagging a new release. +const Version = "0.11.0"