From 9e76f151b9e98c51aa5f70d3891beb6cee62d18a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 10:34:38 -0500 Subject: [PATCH 01/10] 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 4b593408d..13a2a06d7 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 000000000..ec705cc49 --- /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 eedc4237b..c11d988d3 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 000000000..e4ec3b83d --- /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 000000000..167cb0fbb --- /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 000000000..771af6876 --- /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 7ca74b9c7..1e289ad0f 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 7592b18b2..d696faf01 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 000000000..ff6fd6a5a --- /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" From 059b8880109ada7c80d1c2686d336021404e14ef Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 11:40:17 -0500 Subject: [PATCH 02/10] feat(useragent): forward UA to provider wrappers --- providers/anthropic/anthropic.go | 12 +- providers/anthropic/useragent_test.go | 10 +- providers/azure/azure.go | 17 +++ providers/azure/useragent_test.go | 125 ++++++++++++++++++ providers/bedrock/bedrock.go | 17 +++ providers/bedrock/useragent_test.go | 173 +++++++++++++++++++++++++ providers/google/google.go | 16 +-- providers/google/useragent_test.go | 10 +- providers/openai/openai.go | 12 +- providers/openai/openai_test.go | 10 +- providers/openaicompat/openaicompat.go | 17 +++ providers/openrouter/openrouter.go | 17 +++ providers/openrouter/useragent_test.go | 132 +++++++++++++++++++ providers/vercel/vercel.go | 17 +++ 14 files changed, 550 insertions(+), 35 deletions(-) create mode 100644 providers/azure/useragent_test.go create mode 100644 providers/bedrock/useragent_test.go create mode 100644 providers/openrouter/useragent_test.go diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 13a2a06d7..6e574e463 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -37,7 +37,7 @@ type options struct { name string headers map[string]string userAgent string - agentSegment string + modelSegment string client option.HTTPClient vertexProject string @@ -136,12 +136,12 @@ func WithUserAgent(ua string) Option { } } -// WithAgentSegment sets the agent segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string +// WithModelSegment sets the model 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 { +func WithModelSegment(model string) Option { return func(o *options) { - o.agentSegment = agent + o.modelSegment = model } } @@ -166,7 +166,7 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L if a.options.baseURL != "" { clientOptions = append(clientOptions, option.WithBaseURL(a.options.baseURL)) } - defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.agentSegment) + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.modelSegment) resolved := httpheaders.ResolveHeaders(a.options.headers, a.options.userAgent, defaultUA) for key, value := range resolved { clientOptions = append(clientOptions, option.WithHeader(key, value)) diff --git a/providers/anthropic/useragent_test.go b/providers/anthropic/useragent_test.go index ec705cc49..dcc1f517d 100644 --- a/providers/anthropic/useragent_test.go +++ b/providers/anthropic/useragent_test.go @@ -52,12 +52,12 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"]) }) - t.Run("agent segment format", func(t *testing.T) { + t.Run("model 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")) + p, err := New(WithAPIKey("k"), WithBaseURL(server.URL), WithModelSegment("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}) @@ -99,7 +99,7 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) }) - t.Run("WithAgentSegment empty clears segment", func(t *testing.T) { + t.Run("WithModelSegment empty clears segment", func(t *testing.T) { t.Parallel() server, captured := newUAServer() defer server.Close() @@ -107,8 +107,8 @@ func TestUserAgent(t *testing.T) { p, err := New( WithAPIKey("k"), WithBaseURL(server.URL), - WithAgentSegment("initial"), - WithAgentSegment(""), + WithModelSegment("initial"), + WithModelSegment(""), ) require.NoError(t, err) model, _ := p.LanguageModel(t.Context(), "claude-sonnet-4-20250514") diff --git a/providers/azure/azure.go b/providers/azure/azure.go index 68adc2f7a..7b42611d8 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -109,6 +109,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.openaiOptions = append(o.openaiOptions, openai.WithUserAgent(ua)) + } +} + +// WithModelSegment sets the model segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithModelSegment(model string) Option { + return func(o *options) { + o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) + } +} + // WithUseResponsesAPI configures the provider to use the responses API for models that support it. func WithUseResponsesAPI() Option { return func(o *options) { diff --git a/providers/azure/useragent_test.go b/providers/azure/useragent_test.go new file mode 100644 index 000000000..914720781 --- /dev/null +++ b/providers/azure/useragent_test.go @@ -0,0 +1,125 @@ +package azure + +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(mockOpenAIResponse()) + })) + 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(), "gpt-4") + _, _ = 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("model segment format", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New(WithAPIKey("k"), WithBaseURL(server.URL), WithModelSegment("Claude 4.6 Opus")) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = 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("WithUserAgent wins over default", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New(WithAPIKey("k"), WithBaseURL(server.URL), WithUserAgent("explicit-ua")) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) + }) + + t.Run("WithUserAgent wins over WithHeaders", 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(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) + }) +} + +func mockOpenAIResponse() map[string]any { + return map[string]any{ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1711115037, + "model": "gpt-4", + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "Hi there", + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]any{ + "prompt_tokens": 4, + "total_tokens": 6, + "completion_tokens": 2, + }, + } +} diff --git a/providers/bedrock/bedrock.go b/providers/bedrock/bedrock.go index 215021c18..8b216f190 100644 --- a/providers/bedrock/bedrock.go +++ b/providers/bedrock/bedrock.go @@ -57,6 +57,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.anthropicOptions = append(o.anthropicOptions, anthropic.WithUserAgent(ua)) + } +} + +// WithModelSegment sets the model segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithModelSegment(model string) Option { + return func(o *options) { + o.anthropicOptions = append(o.anthropicOptions, anthropic.WithModelSegment(model)) + } +} + // WithSkipAuth configures whether to skip authentication for the Bedrock provider. func WithSkipAuth(skipAuth bool) Option { return func(o *options) { diff --git a/providers/bedrock/useragent_test.go b/providers/bedrock/useragent_test.go new file mode 100644 index 000000000..02b068f5a --- /dev/null +++ b/providers/bedrock/useragent_test.go @@ -0,0 +1,173 @@ +package bedrock + +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(mockAnthropicResponse()) + })) + 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"), + WithSkipAuth(true), + WithHTTPClient(&http.Client{Transport: redirectTransport(server.URL)}), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "us.anthropic.claude-sonnet-4-20250514-v1:0") + _, _ = 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("model segment format", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithAPIKey("k"), + WithSkipAuth(true), + WithHTTPClient(&http.Client{Transport: redirectTransport(server.URL)}), + WithModelSegment("Claude 4.6 Opus"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "us.anthropic.claude-sonnet-4-20250514-v1:0") + _, _ = 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("WithUserAgent wins over default", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithAPIKey("k"), + WithSkipAuth(true), + WithHTTPClient(&http.Client{Transport: redirectTransport(server.URL)}), + WithUserAgent("explicit-ua"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "us.anthropic.claude-sonnet-4-20250514-v1:0") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) + }) + + t.Run("WithUserAgent wins over WithHeaders", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New( + WithAPIKey("k"), + WithSkipAuth(true), + WithHTTPClient(&http.Client{Transport: redirectTransport(server.URL)}), + WithHeaders(map[string]string{"User-Agent": "from-headers"}), + WithUserAgent("explicit-ua"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "us.anthropic.claude-sonnet-4-20250514-v1:0") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) + }) +} + +type redirectRoundTripper struct { + target string +} + +func redirectTransport(target string) *redirectRoundTripper { + return &redirectRoundTripper{target: target} +} + +func (rt *redirectRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = "http" + req.URL.Host = (&redirectRoundTripper{target: rt.target}).host() + return http.DefaultTransport.RoundTrip(req) +} + +func (rt *redirectRoundTripper) host() string { + u := rt.target + if len(u) > 7 && u[:7] == "http://" { + return u[7:] + } + if len(u) > 8 && u[:8] == "https://" { + return u[8:] + } + return u +} + +func mockAnthropicResponse() map[string]any { + return map[string]any{ + "id": "msg_01Test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": []any{ + map[string]any{ + "type": "text", + "text": "Hi there", + }, + }, + "stop_reason": "end_turn", + "stop_sequence": "", + "usage": map[string]any{ + "cache_creation": map[string]any{ + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 0, + }, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 5, + "output_tokens": 2, + "server_tool_use": map[string]any{ + "web_search_requests": 0, + }, + "service_tier": "standard", + }, + } +} diff --git a/providers/google/google.go b/providers/google/google.go index c11d988d3..1830a7cc2 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -38,7 +38,7 @@ type options struct { baseURL string headers map[string]string userAgent string - agentSegment string + modelSegment string client *http.Client backend genai.Backend project string @@ -143,12 +143,12 @@ func WithUserAgent(ua string) Option { } } -// WithAgentSegment sets the agent segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string +// WithModelSegment sets the model 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 { +func WithModelSegment(model string) Option { return func(o *options) { - o.agentSegment = agent + o.modelSegment = model } } @@ -182,8 +182,8 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L if a.options.userAgent != "" { anthropicOpts = append(anthropicOpts, anthropic.WithUserAgent(a.options.userAgent)) } - if a.options.agentSegment != "" { - anthropicOpts = append(anthropicOpts, anthropic.WithAgentSegment(a.options.agentSegment)) + if a.options.modelSegment != "" { + anthropicOpts = append(anthropicOpts, anthropic.WithModelSegment(a.options.modelSegment)) } p, err := anthropic.New(anthropicOpts...) if err != nil { @@ -207,7 +207,7 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L } } - defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.agentSegment) + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.modelSegment) resolved := httpheaders.ResolveHeaders(a.options.headers, a.options.userAgent, defaultUA) headers := http.Header{} diff --git a/providers/google/useragent_test.go b/providers/google/useragent_test.go index e4ec3b83d..82e36b19c 100644 --- a/providers/google/useragent_test.go +++ b/providers/google/useragent_test.go @@ -83,7 +83,7 @@ func TestUserAgent(t *testing.T) { assert.True(t, findUA(captured, "Charm Fantasy/"+fantasy.Version)) }) - t.Run("agent segment format", func(t *testing.T) { + t.Run("model segment format", func(t *testing.T) { t.Parallel() server, captured := newUAServer() defer server.Close() @@ -92,7 +92,7 @@ func TestUserAgent(t *testing.T) { WithVertex("test-project", "us-central1"), WithBaseURL(server.URL), WithSkipAuth(true), - WithAgentSegment("Claude 4.6 Opus"), + WithModelSegment("Claude 4.6 Opus"), ) require.NoError(t, err) model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") @@ -164,7 +164,7 @@ func TestUserAgent(t *testing.T) { assert.True(t, findUA(captured, "explicit-ua")) }) - t.Run("WithAgentSegment empty clears segment", func(t *testing.T) { + t.Run("WithModelSegment empty clears segment", func(t *testing.T) { t.Parallel() server, captured := newUAServer() defer server.Close() @@ -173,8 +173,8 @@ func TestUserAgent(t *testing.T) { WithVertex("test-project", "us-central1"), WithBaseURL(server.URL), WithSkipAuth(true), - WithAgentSegment("initial"), - WithAgentSegment(""), + WithModelSegment("initial"), + WithModelSegment(""), ) require.NoError(t, err) model, err := p.LanguageModel(t.Context(), "gemini-2.0-flash") diff --git a/providers/openai/openai.go b/providers/openai/openai.go index 1e289ad0f..a66c1da9b 100644 --- a/providers/openai/openai.go +++ b/providers/openai/openai.go @@ -32,7 +32,7 @@ type options struct { useResponsesAPI bool headers map[string]string userAgent string - agentSegment string + modelSegment string client option.HTTPClient sdkOptions []option.RequestOption objectMode fantasy.ObjectMode @@ -143,12 +143,12 @@ func WithUserAgent(ua string) Option { } } -// WithAgentSegment sets the agent segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string +// WithModelSegment sets the model 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 { +func WithModelSegment(model string) Option { return func(o *options) { - o.agentSegment = agent + o.modelSegment = model } } @@ -175,7 +175,7 @@ func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.Lan openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(o.options.baseURL)) } - defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, o.options.agentSegment) + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, o.options.modelSegment) 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 d696faf01..90966d067 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -3323,14 +3323,14 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "Charm Fantasy/"+fantasy.Version, server.calls[0].headers["User-Agent"]) }) - t.Run("agent segment format", func(t *testing.T) { + t.Run("model 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")) + p, err := New(WithAPIKey("k"), WithBaseURL(server.server.URL), WithModelSegment("Claude 4.6 Opus")) require.NoError(t, err) model, _ := p.LanguageModel(t.Context(), "gpt-4") _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: testPrompt}) @@ -3376,7 +3376,7 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "explicit-ua", server.calls[0].headers["User-Agent"]) }) - t.Run("WithAgentSegment empty clears segment", func(t *testing.T) { + t.Run("WithModelSegment empty clears segment", func(t *testing.T) { t.Parallel() server := newMockServer() @@ -3386,8 +3386,8 @@ func TestUserAgent(t *testing.T) { p, err := New( WithAPIKey("k"), WithBaseURL(server.server.URL), - WithAgentSegment("initial"), - WithAgentSegment(""), + WithModelSegment("initial"), + WithModelSegment(""), ) require.NoError(t, err) model, _ := p.LanguageModel(t.Context(), "gpt-4") diff --git a/providers/openaicompat/openaicompat.go b/providers/openaicompat/openaicompat.go index 3595a6e42..39a5e684e 100644 --- a/providers/openaicompat/openaicompat.go +++ b/providers/openaicompat/openaicompat.go @@ -108,6 +108,23 @@ func WithObjectMode(om fantasy.ObjectMode) 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.openaiOptions = append(o.openaiOptions, openai.WithUserAgent(ua)) + } +} + +// WithModelSegment sets the model segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithModelSegment(model string) Option { + return func(o *options) { + o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) + } +} + // WithUseResponsesAPI configures the provider to use the responses API for models that support it. func WithUseResponsesAPI() Option { return func(o *options) { diff --git a/providers/openrouter/openrouter.go b/providers/openrouter/openrouter.go index bd0e700af..dfbb0345c 100644 --- a/providers/openrouter/openrouter.go +++ b/providers/openrouter/openrouter.go @@ -89,6 +89,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.openaiOptions = append(o.openaiOptions, openai.WithUserAgent(ua)) + } +} + +// WithModelSegment sets the model segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithModelSegment(model string) Option { + return func(o *options) { + o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) + } +} + // WithObjectMode sets the object generation mode for the OpenRouter provider. // Supported modes: ObjectModeTool, ObjectModeText. // ObjectModeAuto and ObjectModeJSON are automatically converted to ObjectModeTool diff --git a/providers/openrouter/useragent_test.go b/providers/openrouter/useragent_test.go new file mode 100644 index 000000000..b00acd6cb --- /dev/null +++ b/providers/openrouter/useragent_test.go @@ -0,0 +1,132 @@ +package openrouter + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "charm.land/fantasy" + "charm.land/fantasy/providers/openai" + "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(mockOpenAIResponse()) + })) + return server, &captured + } + + withBaseURL := func(url string) Option { + return func(o *options) { + o.openaiOptions = append(o.openaiOptions, openai.WithBaseURL(url)) + } + } + + 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(), "openai/gpt-4") + _, _ = 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("model segment format", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New(WithAPIKey("k"), withBaseURL(server.URL), WithModelSegment("Claude 4.6 Opus")) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "openai/gpt-4") + _, _ = 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("WithUserAgent wins over default", func(t *testing.T) { + t.Parallel() + server, captured := newUAServer() + defer server.Close() + + p, err := New(WithAPIKey("k"), withBaseURL(server.URL), WithUserAgent("explicit-ua")) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "openai/gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) + }) + + t.Run("WithUserAgent wins over WithHeaders", 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(), "openai/gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt}) + + require.Len(t, *captured, 1) + assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) + }) +} + +func mockOpenAIResponse() map[string]any { + return map[string]any{ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1711115037, + "model": "openai/gpt-4", + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "Hi there", + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]any{ + "prompt_tokens": 4, + "total_tokens": 6, + "completion_tokens": 2, + }, + } +} diff --git a/providers/vercel/vercel.go b/providers/vercel/vercel.go index af87db5eb..1dd3ee7c6 100644 --- a/providers/vercel/vercel.go +++ b/providers/vercel/vercel.go @@ -96,6 +96,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.openaiOptions = append(o.openaiOptions, openai.WithUserAgent(ua)) + } +} + +// WithModelSegment sets the model segment appended to the default User-Agent. +// The resulting header is "Fantasy/ ()". Pass an empty string +// to clear a previously set segment. +func WithModelSegment(model string) Option { + return func(o *options) { + o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) + } +} + // WithSDKOptions sets the SDK options for the Vercel provider. func WithSDKOptions(opts ...option.RequestOption) Option { return func(o *options) { From 08d368d3e67ed95b4a017e57a4a937de09206f84 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 13:31:38 -0500 Subject: [PATCH 03/10] feat(useragent): allow UA to be set on the agent level --- agent.go | 24 +++ agent_useragent_test.go | 137 ++++++++++++++++ model.go | 5 + object.go | 5 + providers/anthropic/anthropic.go | 4 +- providers/anthropic/call_useragent.go | 14 ++ providers/google/call_useragent.go | 53 ++++++ providers/google/google.go | 6 +- providers/internal/httpheaders/httpheaders.go | 18 +++ .../internal/httpheaders/httpheaders_test.go | 25 +++ providers/openai/call_useragent.go | 25 +++ providers/openai/language_model.go | 9 +- providers/openai/openai_test.go | 153 ++++++++++++++++++ providers/openai/responses_language_model.go | 8 +- 14 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 agent_useragent_test.go create mode 100644 providers/anthropic/call_useragent.go create mode 100644 providers/google/call_useragent.go create mode 100644 providers/openai/call_useragent.go diff --git a/agent.go b/agent.go index 426beff16..32c8849dd 100644 --- a/agent.go +++ b/agent.go @@ -138,6 +138,8 @@ type agentSettings struct { presencePenalty *float64 frequencyPenalty *float64 headers map[string]string + userAgent string + modelSegment string providerOptions ProviderOptions // TODO: add support for provider tools @@ -448,6 +450,8 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err FrequencyPenalty: opts.FrequencyPenalty, Tools: preparedTools, ToolChoice: &stepToolChoice, + UserAgent: a.settings.userAgent, + ModelSegment: a.settings.modelSegment, ProviderOptions: opts.ProviderOptions, }) }) @@ -829,6 +833,8 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult, FrequencyPenalty: call.FrequencyPenalty, Tools: preparedTools, ToolChoice: &stepToolChoice, + UserAgent: a.settings.userAgent, + ModelSegment: a.settings.modelSegment, ProviderOptions: call.ProviderOptions, } @@ -1418,6 +1424,24 @@ func WithHeaders(headers map[string]string) AgentOption { } } +// WithUserAgent sets the User-Agent header for the agent. This overrides any +// provider-level User-Agent setting. +func WithUserAgent(ua string) AgentOption { + return func(s *agentSettings) { + s.userAgent = ua + } +} + +// WithModelSegment sets the model segment appended to the default User-Agent +// header. The default UA becomes "Fantasy/ ()". An empty +// string clears any previously set segment. This is overridden by WithUserAgent +// at either the agent or provider level. +func WithModelSegment(segment string) AgentOption { + return func(s *agentSettings) { + s.modelSegment = segment + } +} + // WithProviderOptions sets the provider options for the agent. func WithProviderOptions(providerOptions ProviderOptions) AgentOption { return func(s *agentSettings) { diff --git a/agent_useragent_test.go b/agent_useragent_test.go new file mode 100644 index 000000000..76e25b103 --- /dev/null +++ b/agent_useragent_test.go @@ -0,0 +1,137 @@ +package fantasy + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAgent_WithUserAgent_PropagatesOnGenerate(t *testing.T) { + t.Parallel() + + var capturedCall Call + model := &mockLanguageModel{ + generateFunc: func(_ context.Context, call Call) (*Response, error) { + capturedCall = call + return &Response{ + Content: []Content{TextContent{Text: "ok"}}, + FinishReason: FinishReasonStop, + }, nil + }, + } + + agent := NewAgent(model, WithUserAgent("MyApp/2.0")) + _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) + require.NoError(t, err) + assert.Equal(t, "MyApp/2.0", capturedCall.UserAgent) + assert.Empty(t, capturedCall.ModelSegment) +} + +func TestAgent_WithModelSegment_PropagatesOnGenerate(t *testing.T) { + t.Parallel() + + var capturedCall Call + model := &mockLanguageModel{ + generateFunc: func(_ context.Context, call Call) (*Response, error) { + capturedCall = call + return &Response{ + Content: []Content{TextContent{Text: "ok"}}, + FinishReason: FinishReasonStop, + }, nil + }, + } + + agent := NewAgent(model, WithModelSegment("Claude 4.6 Opus")) + _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) + require.NoError(t, err) + assert.Empty(t, capturedCall.UserAgent) + assert.Equal(t, "Claude 4.6 Opus", capturedCall.ModelSegment) +} + +func TestAgent_WithUserAgent_PropagatesOnStream(t *testing.T) { + t.Parallel() + + var capturedCall Call + model := &mockLanguageModel{ + streamFunc: func(_ context.Context, call Call) (StreamResponse, error) { + capturedCall = call + return func(yield func(StreamPart) bool) { + yield(StreamPart{ + Type: StreamPartTypeFinish, + FinishReason: FinishReasonStop, + }) + }, nil + }, + } + + agent := NewAgent(model, WithUserAgent("StreamApp/1.0")) + _, err := agent.Stream(context.Background(), AgentStreamCall{Prompt: "hi"}) + require.NoError(t, err) + assert.Equal(t, "StreamApp/1.0", capturedCall.UserAgent) +} + +func TestAgent_WithModelSegment_PropagatesOnStream(t *testing.T) { + t.Parallel() + + var capturedCall Call + model := &mockLanguageModel{ + streamFunc: func(_ context.Context, call Call) (StreamResponse, error) { + capturedCall = call + return func(yield func(StreamPart) bool) { + yield(StreamPart{ + Type: StreamPartTypeFinish, + FinishReason: FinishReasonStop, + }) + }, nil + }, + } + + agent := NewAgent(model, WithModelSegment("GPT-5")) + _, err := agent.Stream(context.Background(), AgentStreamCall{Prompt: "hi"}) + require.NoError(t, err) + assert.Equal(t, "GPT-5", capturedCall.ModelSegment) +} + +func TestAgent_NoUA_OmitsCallLevelFields(t *testing.T) { + t.Parallel() + + var capturedCall Call + model := &mockLanguageModel{ + generateFunc: func(_ context.Context, call Call) (*Response, error) { + capturedCall = call + return &Response{ + Content: []Content{TextContent{Text: "ok"}}, + FinishReason: FinishReasonStop, + }, nil + }, + } + + agent := NewAgent(model) + _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) + require.NoError(t, err) + assert.Empty(t, capturedCall.UserAgent) + assert.Empty(t, capturedCall.ModelSegment) +} + +func TestAgent_WithUserAgentAndModelSegment_BothPropagate(t *testing.T) { + t.Parallel() + + var capturedCall Call + model := &mockLanguageModel{ + generateFunc: func(_ context.Context, call Call) (*Response, error) { + capturedCall = call + return &Response{ + Content: []Content{TextContent{Text: "ok"}}, + FinishReason: FinishReasonStop, + }, nil + }, + } + + agent := NewAgent(model, WithUserAgent("App/1.0"), WithModelSegment("Claude 4.6")) + _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) + require.NoError(t, err) + assert.Equal(t, "App/1.0", capturedCall.UserAgent) + assert.Equal(t, "Claude 4.6", capturedCall.ModelSegment) +} diff --git a/model.go b/model.go index 4d1c3e31b..92dabf337 100644 --- a/model.go +++ b/model.go @@ -218,6 +218,11 @@ type Call struct { Tools []Tool `json:"tools"` ToolChoice *ToolChoice `json:"tool_choice"` + // UserAgent overrides the provider-level User-Agent header for this call. + UserAgent string `json:"-"` + // ModelSegment overrides the provider-level model segment for this call. + ModelSegment string `json:"-"` + // for provider specific options, the key is the provider id ProviderOptions ProviderOptions `json:"provider_options"` } diff --git a/object.go b/object.go index 4b8aed369..3e434e381 100644 --- a/object.go +++ b/object.go @@ -41,6 +41,11 @@ type ObjectCall struct { PresencePenalty *float64 FrequencyPenalty *float64 + // UserAgent overrides the provider-level User-Agent header for this call. + UserAgent string `json:"-"` + // ModelSegment overrides the provider-level model segment for this call. + ModelSegment string `json:"-"` + ProviderOptions ProviderOptions RepairText schema.ObjectRepairFunc diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 6e574e463..dfa3dd379 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -793,7 +793,7 @@ func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas if err != nil { return nil, err } - response, err := a.client.Messages.New(ctx, *params) + response, err := a.client.Messages.New(ctx, *params, callUARequestOptions(call)...) if err != nil { return nil, toProviderErr(err) } @@ -871,7 +871,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S return nil, err } - stream := a.client.Messages.NewStreaming(ctx, *params) + stream := a.client.Messages.NewStreaming(ctx, *params, callUARequestOptions(call)...) acc := anthropic.Message{} return func(yield func(fantasy.StreamPart) bool) { if len(warnings) > 0 { diff --git a/providers/anthropic/call_useragent.go b/providers/anthropic/call_useragent.go new file mode 100644 index 000000000..d1fc97780 --- /dev/null +++ b/providers/anthropic/call_useragent.go @@ -0,0 +1,14 @@ +package anthropic + +import ( + "charm.land/fantasy" + "charm.land/fantasy/providers/internal/httpheaders" + "github.com/charmbracelet/anthropic-sdk-go/option" +) + +func callUARequestOptions(call fantasy.Call) []option.RequestOption { + if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + return []option.RequestOption{option.WithHeader("User-Agent", ua)} + } + return nil +} diff --git a/providers/google/call_useragent.go b/providers/google/call_useragent.go new file mode 100644 index 000000000..57d5d3927 --- /dev/null +++ b/providers/google/call_useragent.go @@ -0,0 +1,53 @@ +package google + +import ( + "context" + "net/http" + + "charm.land/fantasy" + "charm.land/fantasy/providers/internal/httpheaders" +) + +type callUAKey struct{} + +func withCallUA(ctx context.Context, call fantasy.Call) context.Context { + if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + return context.WithValue(ctx, callUAKey{}, ua) + } + return ctx +} + +func withObjectCallUA(ctx context.Context, call fantasy.ObjectCall) context.Context { + if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + return context.WithValue(ctx, callUAKey{}, ua) + } + return ctx +} + +func wrapHTTPClient(c *http.Client) *http.Client { + if c == nil { + c = http.DefaultClient + } + transport := c.Transport + if transport == nil { + transport = http.DefaultTransport + } + return &http.Client{ + Transport: &uaTransport{base: transport}, + CheckRedirect: c.CheckRedirect, + Jar: c.Jar, + Timeout: c.Timeout, + } +} + +type uaTransport struct { + base http.RoundTripper +} + +func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if ua, ok := req.Context().Value(callUAKey{}).(string); ok && ua != "" { + req = req.Clone(req.Context()) + req.Header.Set("User-Agent", ua) + } + return t.base.RoundTrip(req) +} diff --git a/providers/google/google.go b/providers/google/google.go index 1830a7cc2..2b9dd6b08 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -193,7 +193,7 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L } cc := &genai.ClientConfig{ - HTTPClient: a.options.client, + HTTPClient: wrapHTTPClient(a.options.client), Backend: a.options.backend, APIKey: a.options.apiKey, Project: a.options.project, @@ -558,6 +558,7 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, [] // Generate implements fantasy.LanguageModel. func (g *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { + ctx = withCallUA(ctx, call) config, contents, warnings, err := g.prepareParams(call) if err != nil { return nil, err @@ -593,6 +594,7 @@ func (g *languageModel) Provider() string { // Stream implements fantasy.LanguageModel. func (g *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + ctx = withCallUA(ctx, call) config, contents, warnings, err := g.prepareParams(call) if err != nil { return nil, err @@ -919,6 +921,7 @@ func (g *languageModel) StreamObject(ctx context.Context, call fantasy.ObjectCal } func (g *languageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + ctx = withObjectCallUA(ctx, call) // Convert our Schema to Google's JSON Schema format jsonSchemaMap := schema.ToMap(call.Schema) @@ -1001,6 +1004,7 @@ func (g *languageModel) generateObjectWithJSONMode(ctx context.Context, call fan } func (g *languageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) { + ctx = withObjectCallUA(ctx, call) // Convert our Schema to Google's JSON Schema format jsonSchemaMap := schema.ToMap(call.Schema) diff --git a/providers/internal/httpheaders/httpheaders.go b/providers/internal/httpheaders/httpheaders.go index 167cb0fbb..263ff2046 100644 --- a/providers/internal/httpheaders/httpheaders.go +++ b/providers/internal/httpheaders/httpheaders.go @@ -52,6 +52,24 @@ func ResolveHeaders(headers map[string]string, explicitUA, defaultUA string) map return out } +// CallUserAgent resolves the User-Agent for a single API call. It returns the +// resolved UA string and true if a per-call override should be applied, or +// empty string and false if the client-level UA should be used as-is. +// +// Precedence: +// 1. callUA (agent-level WithUserAgent) — highest +// 2. callSegment used to build default UA (agent-level WithModelSegment) +// 3. empty — use client-level UA (return false) +func CallUserAgent(version, callUA, callSegment string) (string, bool) { + if callUA != "" { + return callUA, true + } + if callSegment != "" { + return DefaultUserAgent(version, callSegment), true + } + return "", false +} + func sanitizeAgent(s string) string { s = strings.TrimSpace(s) var b strings.Builder diff --git a/providers/internal/httpheaders/httpheaders_test.go b/providers/internal/httpheaders/httpheaders_test.go index 771af6876..b04cc4aae 100644 --- a/providers/internal/httpheaders/httpheaders_test.go +++ b/providers/internal/httpheaders/httpheaders_test.go @@ -139,3 +139,28 @@ func TestResolveHeaders_DuplicateCaseInsensitiveKeys(t *testing.T) { _, hasLower := got["user-agent"] assert.False(t, hasLower, "all case-insensitive UA keys must be removed") } + +func TestCallUserAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + callUA string + callSegment string + wantUA string + wantOK bool + }{ + {name: "no override", callUA: "", callSegment: "", wantUA: "", wantOK: false}, + {name: "explicit UA", callUA: "MyAgent/1.0", callSegment: "", wantUA: "MyAgent/1.0", wantOK: true}, + {name: "model segment only", callUA: "", callSegment: "Claude 4.6", wantUA: "Charm Fantasy/0.11.0 (Claude 4.6)", wantOK: true}, + {name: "explicit UA wins over segment", callUA: "MyAgent/1.0", callSegment: "Claude 4.6", wantUA: "MyAgent/1.0", wantOK: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ua, ok := CallUserAgent("0.11.0", tt.callUA, tt.callSegment) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantUA, ua) + }) + } +} diff --git a/providers/openai/call_useragent.go b/providers/openai/call_useragent.go new file mode 100644 index 000000000..007ded93b --- /dev/null +++ b/providers/openai/call_useragent.go @@ -0,0 +1,25 @@ +package openai + +import ( + "charm.land/fantasy" + "charm.land/fantasy/providers/internal/httpheaders" + "github.com/openai/openai-go/v2/option" +) + +// callUARequestOptions returns per-request options that override the +// client-level User-Agent header when the Call carries agent-level UA settings. +func callUARequestOptions(call fantasy.Call) []option.RequestOption { + if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + return []option.RequestOption{option.WithHeader("User-Agent", ua)} + } + return nil +} + +// objectCallUARequestOptions returns per-request options that override the +// client-level User-Agent header when the ObjectCall carries agent-level UA settings. +func objectCallUARequestOptions(call fantasy.ObjectCall) []option.RequestOption { + if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + return []option.RequestOption{option.WithHeader("User-Agent", ua)} + } + return nil +} diff --git a/providers/openai/language_model.go b/providers/openai/language_model.go index 9df357ac8..ae3d87649 100644 --- a/providers/openai/language_model.go +++ b/providers/openai/language_model.go @@ -246,7 +246,7 @@ func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas if err != nil { return nil, err } - response, err := o.client.Chat.Completions.New(ctx, *params) + response, err := o.client.Chat.Completions.New(ctx, *params, callUARequestOptions(call)...) if err != nil { return nil, toProviderErr(err) } @@ -314,7 +314,7 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S IncludeUsage: openai.Bool(true), } - stream := o.client.Chat.Completions.NewStreaming(ctx, *params) + stream := o.client.Chat.Completions.NewStreaming(ctx, *params, callUARequestOptions(call)...) isActiveText := false toolCalls := make(map[int64]streamToolCall) @@ -733,11 +733,10 @@ func (o languageModel) generateObjectWithJSONMode(ctx context.Context, call fant }, } - response, err := o.client.Chat.Completions.New(ctx, *params) + response, err := o.client.Chat.Completions.New(ctx, *params, objectCallUARequestOptions(call)...) if err != nil { return nil, toProviderErr(err) } - if len(response.Choices) == 0 { usage, _ := o.usageFunc(*response) return nil, &fantasy.NoObjectGeneratedError{ @@ -818,7 +817,7 @@ func (o languageModel) streamObjectWithJSONMode(ctx context.Context, call fantas IncludeUsage: openai.Bool(true), } - stream := o.client.Chat.Completions.NewStreaming(ctx, *params) + stream := o.client.Chat.Completions.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...) return func(yield func(fantasy.ObjectStreamPart) bool) { if len(warnings) > 0 { diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 90966d067..67d863bea 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -3396,4 +3396,157 @@ func TestUserAgent(t *testing.T) { require.Len(t, server.calls, 1) assert.Equal(t, "Charm Fantasy/"+fantasy.Version, server.calls[0].headers["User-Agent"]) }) + + t.Run("Call.UserAgent overrides provider UA", 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), + WithUserAgent("provider-ua"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{ + Prompt: testPrompt, + UserAgent: "agent-ua", + }) + + require.Len(t, server.calls, 1) + assert.Equal(t, "agent-ua", server.calls[0].headers["User-Agent"]) + }) + + t.Run("Call.ModelSegment overrides provider 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), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{ + Prompt: testPrompt, + ModelSegment: "GPT-5", + }) + + require.Len(t, server.calls, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version+" (GPT-5)", server.calls[0].headers["User-Agent"]) + }) + + t.Run("Call.UserAgent overrides provider WithHeaders UA", 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": "header-ua"}), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + _, _ = model.Generate(t.Context(), fantasy.Call{ + Prompt: testPrompt, + UserAgent: "call-level-ua", + }) + + require.Len(t, server.calls, 1) + assert.Equal(t, "call-level-ua", server.calls[0].headers["User-Agent"]) + }) + + t.Run("no Call UA falls through to provider UA", 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), + WithUserAgent("provider-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, "provider-ua", server.calls[0].headers["User-Agent"]) + }) + + t.Run("agent WithUserAgent overrides provider UA end-to-end", 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), + WithUserAgent("provider-ua"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + + agent := fantasy.NewAgent(model, fantasy.WithUserAgent("agent-ua")) + _, _ = agent.Generate(t.Context(), fantasy.AgentCall{Prompt: "hi"}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "agent-ua", server.calls[0].headers["User-Agent"]) + }) + + t.Run("agent WithModelSegment overrides provider default end-to-end", 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") + + agent := fantasy.NewAgent(model, fantasy.WithModelSegment("Claude 4.6")) + _, _ = agent.Generate(t.Context(), fantasy.AgentCall{Prompt: "hi"}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "Charm Fantasy/"+fantasy.Version+" (Claude 4.6)", server.calls[0].headers["User-Agent"]) + }) + + t.Run("agent without UA falls through to provider UA end-to-end", 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), + WithUserAgent("provider-ua"), + ) + require.NoError(t, err) + model, _ := p.LanguageModel(t.Context(), "gpt-4") + + agent := fantasy.NewAgent(model) + _, _ = agent.Generate(t.Context(), fantasy.AgentCall{Prompt: "hi"}) + + require.Len(t, server.calls, 1) + assert.Equal(t, "provider-ua", server.calls[0].headers["User-Agent"]) + }) } diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index 39ee8e427..090117de0 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -668,7 +668,7 @@ func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, opti func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { params, warnings := o.prepareParams(call) - response, err := o.client.Responses.New(ctx, *params) + response, err := o.client.Responses.New(ctx, *params, callUARequestOptions(call)...) if err != nil { return nil, toProviderErr(err) } @@ -806,7 +806,7 @@ func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.Finis func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { params, warnings := o.prepareParams(call) - stream := o.client.Responses.NewStreaming(ctx, *params) + stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call)...) finishReason := fantasy.FinishReasonUnknown var usage fantasy.Usage @@ -1106,7 +1106,7 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, } // Make request - response, err := o.client.Responses.New(ctx, *params) + response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...) if err != nil { return nil, toProviderErr(err) } @@ -1216,7 +1216,7 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap), } - stream := o.client.Responses.NewStreaming(ctx, *params) + stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...) return func(yield func(fantasy.ObjectStreamPart) bool) { if len(warnings) > 0 { From 4228d2efd80a735020d2a7bbb5ec9a8a534a02d0 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 14:21:26 -0500 Subject: [PATCH 04/10] chore(useragent): drop model detail option --- agent.go | 13 --- agent_useragent_test.go | 66 ----------- model.go | 2 - object.go | 2 - providers/anthropic/anthropic.go | 12 +- providers/anthropic/call_useragent.go | 2 +- providers/anthropic/useragent_test.go | 47 -------- providers/azure/azure.go | 9 -- providers/azure/useragent_test.go | 14 --- providers/bedrock/bedrock.go | 9 -- providers/bedrock/useragent_test.go | 19 ---- providers/google/call_useragent.go | 4 +- providers/google/google.go | 15 +-- providers/google/useragent_test.go | 41 ------- providers/internal/httpheaders/httpheaders.go | 47 +------- .../internal/httpheaders/httpheaders_test.go | 58 ++-------- providers/openai/call_useragent.go | 4 +- providers/openai/openai.go | 12 +- providers/openai/openai_test.go | 103 ------------------ providers/openaicompat/openaicompat.go | 9 -- providers/openrouter/openrouter.go | 9 -- providers/openrouter/useragent_test.go | 14 --- providers/vercel/vercel.go | 9 -- 23 files changed, 23 insertions(+), 497 deletions(-) diff --git a/agent.go b/agent.go index 32c8849dd..6d2d62dfb 100644 --- a/agent.go +++ b/agent.go @@ -139,7 +139,6 @@ type agentSettings struct { frequencyPenalty *float64 headers map[string]string userAgent string - modelSegment string providerOptions ProviderOptions // TODO: add support for provider tools @@ -451,7 +450,6 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err Tools: preparedTools, ToolChoice: &stepToolChoice, UserAgent: a.settings.userAgent, - ModelSegment: a.settings.modelSegment, ProviderOptions: opts.ProviderOptions, }) }) @@ -834,7 +832,6 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult, Tools: preparedTools, ToolChoice: &stepToolChoice, UserAgent: a.settings.userAgent, - ModelSegment: a.settings.modelSegment, ProviderOptions: call.ProviderOptions, } @@ -1432,16 +1429,6 @@ func WithUserAgent(ua string) AgentOption { } } -// WithModelSegment sets the model segment appended to the default User-Agent -// header. The default UA becomes "Fantasy/ ()". An empty -// string clears any previously set segment. This is overridden by WithUserAgent -// at either the agent or provider level. -func WithModelSegment(segment string) AgentOption { - return func(s *agentSettings) { - s.modelSegment = segment - } -} - // WithProviderOptions sets the provider options for the agent. func WithProviderOptions(providerOptions ProviderOptions) AgentOption { return func(s *agentSettings) { diff --git a/agent_useragent_test.go b/agent_useragent_test.go index 76e25b103..95cc81d8e 100644 --- a/agent_useragent_test.go +++ b/agent_useragent_test.go @@ -26,28 +26,6 @@ func TestAgent_WithUserAgent_PropagatesOnGenerate(t *testing.T) { _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) require.NoError(t, err) assert.Equal(t, "MyApp/2.0", capturedCall.UserAgent) - assert.Empty(t, capturedCall.ModelSegment) -} - -func TestAgent_WithModelSegment_PropagatesOnGenerate(t *testing.T) { - t.Parallel() - - var capturedCall Call - model := &mockLanguageModel{ - generateFunc: func(_ context.Context, call Call) (*Response, error) { - capturedCall = call - return &Response{ - Content: []Content{TextContent{Text: "ok"}}, - FinishReason: FinishReasonStop, - }, nil - }, - } - - agent := NewAgent(model, WithModelSegment("Claude 4.6 Opus")) - _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) - require.NoError(t, err) - assert.Empty(t, capturedCall.UserAgent) - assert.Equal(t, "Claude 4.6 Opus", capturedCall.ModelSegment) } func TestAgent_WithUserAgent_PropagatesOnStream(t *testing.T) { @@ -72,28 +50,6 @@ func TestAgent_WithUserAgent_PropagatesOnStream(t *testing.T) { assert.Equal(t, "StreamApp/1.0", capturedCall.UserAgent) } -func TestAgent_WithModelSegment_PropagatesOnStream(t *testing.T) { - t.Parallel() - - var capturedCall Call - model := &mockLanguageModel{ - streamFunc: func(_ context.Context, call Call) (StreamResponse, error) { - capturedCall = call - return func(yield func(StreamPart) bool) { - yield(StreamPart{ - Type: StreamPartTypeFinish, - FinishReason: FinishReasonStop, - }) - }, nil - }, - } - - agent := NewAgent(model, WithModelSegment("GPT-5")) - _, err := agent.Stream(context.Background(), AgentStreamCall{Prompt: "hi"}) - require.NoError(t, err) - assert.Equal(t, "GPT-5", capturedCall.ModelSegment) -} - func TestAgent_NoUA_OmitsCallLevelFields(t *testing.T) { t.Parallel() @@ -112,26 +68,4 @@ func TestAgent_NoUA_OmitsCallLevelFields(t *testing.T) { _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) require.NoError(t, err) assert.Empty(t, capturedCall.UserAgent) - assert.Empty(t, capturedCall.ModelSegment) -} - -func TestAgent_WithUserAgentAndModelSegment_BothPropagate(t *testing.T) { - t.Parallel() - - var capturedCall Call - model := &mockLanguageModel{ - generateFunc: func(_ context.Context, call Call) (*Response, error) { - capturedCall = call - return &Response{ - Content: []Content{TextContent{Text: "ok"}}, - FinishReason: FinishReasonStop, - }, nil - }, - } - - agent := NewAgent(model, WithUserAgent("App/1.0"), WithModelSegment("Claude 4.6")) - _, err := agent.Generate(context.Background(), AgentCall{Prompt: "hi"}) - require.NoError(t, err) - assert.Equal(t, "App/1.0", capturedCall.UserAgent) - assert.Equal(t, "Claude 4.6", capturedCall.ModelSegment) } diff --git a/model.go b/model.go index 92dabf337..16980da10 100644 --- a/model.go +++ b/model.go @@ -220,8 +220,6 @@ type Call struct { // UserAgent overrides the provider-level User-Agent header for this call. UserAgent string `json:"-"` - // ModelSegment overrides the provider-level model segment for this call. - ModelSegment string `json:"-"` // for provider specific options, the key is the provider id ProviderOptions ProviderOptions `json:"provider_options"` diff --git a/object.go b/object.go index 3e434e381..3cbcbd3eb 100644 --- a/object.go +++ b/object.go @@ -43,8 +43,6 @@ type ObjectCall struct { // UserAgent overrides the provider-level User-Agent header for this call. UserAgent string `json:"-"` - // ModelSegment overrides the provider-level model segment for this call. - ModelSegment string `json:"-"` ProviderOptions ProviderOptions diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index dfa3dd379..10f845b9d 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -37,7 +37,6 @@ type options struct { name string headers map[string]string userAgent string - modelSegment string client option.HTTPClient vertexProject string @@ -136,15 +135,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.modelSegment = model - } -} - // WithObjectMode sets the object generation mode. func WithObjectMode(om fantasy.ObjectMode) Option { return func(o *options) { @@ -166,7 +156,7 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L if a.options.baseURL != "" { clientOptions = append(clientOptions, option.WithBaseURL(a.options.baseURL)) } - defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.modelSegment) + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version) resolved := httpheaders.ResolveHeaders(a.options.headers, a.options.userAgent, defaultUA) for key, value := range resolved { clientOptions = append(clientOptions, option.WithHeader(key, value)) diff --git a/providers/anthropic/call_useragent.go b/providers/anthropic/call_useragent.go index d1fc97780..4aaffcf87 100644 --- a/providers/anthropic/call_useragent.go +++ b/providers/anthropic/call_useragent.go @@ -7,7 +7,7 @@ import ( ) func callUARequestOptions(call fantasy.Call) []option.RequestOption { - if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + if ua, ok := httpheaders.CallUserAgent(call.UserAgent); ok { return []option.RequestOption{option.WithHeader("User-Agent", ua)} } return nil diff --git a/providers/anthropic/useragent_test.go b/providers/anthropic/useragent_test.go index dcc1f517d..6ae7a4ec1 100644 --- a/providers/anthropic/useragent_test.go +++ b/providers/anthropic/useragent_test.go @@ -52,34 +52,6 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"]) }) - t.Run("model segment format", func(t *testing.T) { - t.Parallel() - server, captured := newUAServer() - defer server.Close() - - p, err := New(WithAPIKey("k"), WithBaseURL(server.URL), WithModelSegment("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() @@ -98,23 +70,4 @@ func TestUserAgent(t *testing.T) { require.Len(t, *captured, 1) assert.Equal(t, "explicit-ua", (*captured)[0]["User-Agent"]) }) - - t.Run("WithModelSegment empty clears segment", func(t *testing.T) { - t.Parallel() - server, captured := newUAServer() - defer server.Close() - - p, err := New( - WithAPIKey("k"), - WithBaseURL(server.URL), - WithModelSegment("initial"), - WithModelSegment(""), - ) - 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/azure/azure.go b/providers/azure/azure.go index 7b42611d8..a68df7251 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -117,15 +117,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) - } -} - // WithUseResponsesAPI configures the provider to use the responses API for models that support it. func WithUseResponsesAPI() Option { return func(o *options) { diff --git a/providers/azure/useragent_test.go b/providers/azure/useragent_test.go index 914720781..c190f5db7 100644 --- a/providers/azure/useragent_test.go +++ b/providers/azure/useragent_test.go @@ -52,20 +52,6 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"]) }) - t.Run("model segment format", func(t *testing.T) { - t.Parallel() - server, captured := newUAServer() - defer server.Close() - - p, err := New(WithAPIKey("k"), WithBaseURL(server.URL), WithModelSegment("Claude 4.6 Opus")) - require.NoError(t, err) - model, _ := p.LanguageModel(t.Context(), "gpt-4") - _, _ = 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("WithUserAgent wins over default", func(t *testing.T) { t.Parallel() server, captured := newUAServer() diff --git a/providers/bedrock/bedrock.go b/providers/bedrock/bedrock.go index 8b216f190..c8889398c 100644 --- a/providers/bedrock/bedrock.go +++ b/providers/bedrock/bedrock.go @@ -65,15 +65,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.anthropicOptions = append(o.anthropicOptions, anthropic.WithModelSegment(model)) - } -} - // WithSkipAuth configures whether to skip authentication for the Bedrock provider. func WithSkipAuth(skipAuth bool) Option { return func(o *options) { diff --git a/providers/bedrock/useragent_test.go b/providers/bedrock/useragent_test.go index 02b068f5a..d6935d6dc 100644 --- a/providers/bedrock/useragent_test.go +++ b/providers/bedrock/useragent_test.go @@ -56,25 +56,6 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"]) }) - t.Run("model segment format", func(t *testing.T) { - t.Parallel() - server, captured := newUAServer() - defer server.Close() - - p, err := New( - WithAPIKey("k"), - WithSkipAuth(true), - WithHTTPClient(&http.Client{Transport: redirectTransport(server.URL)}), - WithModelSegment("Claude 4.6 Opus"), - ) - require.NoError(t, err) - model, _ := p.LanguageModel(t.Context(), "us.anthropic.claude-sonnet-4-20250514-v1:0") - _, _ = 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("WithUserAgent wins over default", func(t *testing.T) { t.Parallel() server, captured := newUAServer() diff --git a/providers/google/call_useragent.go b/providers/google/call_useragent.go index 57d5d3927..a3521dd75 100644 --- a/providers/google/call_useragent.go +++ b/providers/google/call_useragent.go @@ -11,14 +11,14 @@ import ( type callUAKey struct{} func withCallUA(ctx context.Context, call fantasy.Call) context.Context { - if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + if ua, ok := httpheaders.CallUserAgent(call.UserAgent); ok { return context.WithValue(ctx, callUAKey{}, ua) } return ctx } func withObjectCallUA(ctx context.Context, call fantasy.ObjectCall) context.Context { - if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + if ua, ok := httpheaders.CallUserAgent(call.UserAgent); ok { return context.WithValue(ctx, callUAKey{}, ua) } return ctx diff --git a/providers/google/google.go b/providers/google/google.go index 2b9dd6b08..5d3f05152 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -38,7 +38,6 @@ type options struct { baseURL string headers map[string]string userAgent string - modelSegment string client *http.Client backend genai.Backend project string @@ -143,15 +142,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.modelSegment = model - } -} - // WithObjectMode sets the object generation mode for the Google provider. func WithObjectMode(om fantasy.ObjectMode) Option { return func(o *options) { @@ -182,9 +172,6 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L if a.options.userAgent != "" { anthropicOpts = append(anthropicOpts, anthropic.WithUserAgent(a.options.userAgent)) } - if a.options.modelSegment != "" { - anthropicOpts = append(anthropicOpts, anthropic.WithModelSegment(a.options.modelSegment)) - } p, err := anthropic.New(anthropicOpts...) if err != nil { return nil, err @@ -207,7 +194,7 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L } } - defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, a.options.modelSegment) + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version) resolved := httpheaders.ResolveHeaders(a.options.headers, a.options.userAgent, defaultUA) headers := http.Header{} diff --git a/providers/google/useragent_test.go b/providers/google/useragent_test.go index 82e36b19c..1494ff9a8 100644 --- a/providers/google/useragent_test.go +++ b/providers/google/useragent_test.go @@ -83,26 +83,6 @@ func TestUserAgent(t *testing.T) { assert.True(t, findUA(captured, "Charm Fantasy/"+fantasy.Version)) }) - t.Run("model 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), - WithModelSegment("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() @@ -163,25 +143,4 @@ func TestUserAgent(t *testing.T) { require.NotEmpty(t, *captured) assert.True(t, findUA(captured, "explicit-ua")) }) - - t.Run("WithModelSegment 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), - WithModelSegment("initial"), - WithModelSegment(""), - ) - 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 index 263ff2046..c4b205b98 100644 --- a/providers/internal/httpheaders/httpheaders.go +++ b/providers/internal/httpheaders/httpheaders.go @@ -1,23 +1,12 @@ // Package httpheaders provides shared User-Agent resolution for all HTTP-based providers. package httpheaders -import ( - "strings" - "unicode" -) - -const maxAgentLength = 64 +import "strings" // 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 + ")" +// The result is "Charm Fantasy/". +func DefaultUserAgent(version string) string { + return "Charm Fantasy/" + version } // ResolveHeaders returns a new header map with User-Agent resolved according to precedence: @@ -55,35 +44,9 @@ func ResolveHeaders(headers map[string]string, explicitUA, defaultUA string) map // CallUserAgent resolves the User-Agent for a single API call. It returns the // resolved UA string and true if a per-call override should be applied, or // empty string and false if the client-level UA should be used as-is. -// -// Precedence: -// 1. callUA (agent-level WithUserAgent) — highest -// 2. callSegment used to build default UA (agent-level WithModelSegment) -// 3. empty — use client-level UA (return false) -func CallUserAgent(version, callUA, callSegment string) (string, bool) { +func CallUserAgent(callUA string) (string, bool) { if callUA != "" { return callUA, true } - if callSegment != "" { - return DefaultUserAgent(version, callSegment), true - } return "", false } - -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 index b04cc4aae..3a70293a7 100644 --- a/providers/internal/httpheaders/httpheaders_test.go +++ b/providers/internal/httpheaders/httpheaders_test.go @@ -1,7 +1,6 @@ package httpheaders import ( - "strings" "testing" "github.com/stretchr/testify/assert" @@ -14,22 +13,16 @@ func TestDefaultUserAgent(t *testing.T) { 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"}, + {name: "basic version", version: "0.11.0", want: "Charm Fantasy/0.11.0"}, + {name: "another version", version: "1.0.0", 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) + got := DefaultUserAgent(tt.version) assert.Equal(t, tt.want, got) }) } @@ -99,34 +92,6 @@ func TestResolveHeaders_PreservesOtherHeaders(t *testing.T) { 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() @@ -144,21 +109,18 @@ func TestCallUserAgent(t *testing.T) { t.Parallel() tests := []struct { - name string - callUA string - callSegment string - wantUA string - wantOK bool + name string + callUA string + wantUA string + wantOK bool }{ - {name: "no override", callUA: "", callSegment: "", wantUA: "", wantOK: false}, - {name: "explicit UA", callUA: "MyAgent/1.0", callSegment: "", wantUA: "MyAgent/1.0", wantOK: true}, - {name: "model segment only", callUA: "", callSegment: "Claude 4.6", wantUA: "Charm Fantasy/0.11.0 (Claude 4.6)", wantOK: true}, - {name: "explicit UA wins over segment", callUA: "MyAgent/1.0", callSegment: "Claude 4.6", wantUA: "MyAgent/1.0", wantOK: true}, + {name: "no override", callUA: "", wantUA: "", wantOK: false}, + {name: "explicit UA", callUA: "MyAgent/1.0", wantUA: "MyAgent/1.0", wantOK: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ua, ok := CallUserAgent("0.11.0", tt.callUA, tt.callSegment) + ua, ok := CallUserAgent(tt.callUA) assert.Equal(t, tt.wantOK, ok) assert.Equal(t, tt.wantUA, ua) }) diff --git a/providers/openai/call_useragent.go b/providers/openai/call_useragent.go index 007ded93b..4c7f2a4f7 100644 --- a/providers/openai/call_useragent.go +++ b/providers/openai/call_useragent.go @@ -9,7 +9,7 @@ import ( // callUARequestOptions returns per-request options that override the // client-level User-Agent header when the Call carries agent-level UA settings. func callUARequestOptions(call fantasy.Call) []option.RequestOption { - if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + if ua, ok := httpheaders.CallUserAgent(call.UserAgent); ok { return []option.RequestOption{option.WithHeader("User-Agent", ua)} } return nil @@ -18,7 +18,7 @@ func callUARequestOptions(call fantasy.Call) []option.RequestOption { // objectCallUARequestOptions returns per-request options that override the // client-level User-Agent header when the ObjectCall carries agent-level UA settings. func objectCallUARequestOptions(call fantasy.ObjectCall) []option.RequestOption { - if ua, ok := httpheaders.CallUserAgent(fantasy.Version, call.UserAgent, call.ModelSegment); ok { + if ua, ok := httpheaders.CallUserAgent(call.UserAgent); ok { return []option.RequestOption{option.WithHeader("User-Agent", ua)} } return nil diff --git a/providers/openai/openai.go b/providers/openai/openai.go index a66c1da9b..928f3dd28 100644 --- a/providers/openai/openai.go +++ b/providers/openai/openai.go @@ -32,7 +32,6 @@ type options struct { useResponsesAPI bool headers map[string]string userAgent string - modelSegment string client option.HTTPClient sdkOptions []option.RequestOption objectMode fantasy.ObjectMode @@ -143,15 +142,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.modelSegment = model - } -} - // WithObjectMode sets the object generation mode. func WithObjectMode(om fantasy.ObjectMode) Option { return func(o *options) { @@ -175,7 +165,7 @@ func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.Lan openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(o.options.baseURL)) } - defaultUA := httpheaders.DefaultUserAgent(fantasy.Version, o.options.modelSegment) + defaultUA := httpheaders.DefaultUserAgent(fantasy.Version) 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 67d863bea..3e56aeb09 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -3323,22 +3323,6 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "Charm Fantasy/"+fantasy.Version, server.calls[0].headers["User-Agent"]) }) - t.Run("model 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), WithModelSegment("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() @@ -3376,72 +3360,6 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "explicit-ua", server.calls[0].headers["User-Agent"]) }) - t.Run("WithModelSegment 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), - WithModelSegment("initial"), - WithModelSegment(""), - ) - 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("Call.UserAgent overrides provider UA", 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), - WithUserAgent("provider-ua"), - ) - require.NoError(t, err) - model, _ := p.LanguageModel(t.Context(), "gpt-4") - _, _ = model.Generate(t.Context(), fantasy.Call{ - Prompt: testPrompt, - UserAgent: "agent-ua", - }) - - require.Len(t, server.calls, 1) - assert.Equal(t, "agent-ua", server.calls[0].headers["User-Agent"]) - }) - - t.Run("Call.ModelSegment overrides provider 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), - ) - require.NoError(t, err) - model, _ := p.LanguageModel(t.Context(), "gpt-4") - _, _ = model.Generate(t.Context(), fantasy.Call{ - Prompt: testPrompt, - ModelSegment: "GPT-5", - }) - - require.Len(t, server.calls, 1) - assert.Equal(t, "Charm Fantasy/"+fantasy.Version+" (GPT-5)", server.calls[0].headers["User-Agent"]) - }) - t.Run("Call.UserAgent overrides provider WithHeaders UA", func(t *testing.T) { t.Parallel() @@ -3507,27 +3425,6 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "agent-ua", server.calls[0].headers["User-Agent"]) }) - t.Run("agent WithModelSegment overrides provider default end-to-end", 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") - - agent := fantasy.NewAgent(model, fantasy.WithModelSegment("Claude 4.6")) - _, _ = agent.Generate(t.Context(), fantasy.AgentCall{Prompt: "hi"}) - - require.Len(t, server.calls, 1) - assert.Equal(t, "Charm Fantasy/"+fantasy.Version+" (Claude 4.6)", server.calls[0].headers["User-Agent"]) - }) - t.Run("agent without UA falls through to provider UA end-to-end", func(t *testing.T) { t.Parallel() diff --git a/providers/openaicompat/openaicompat.go b/providers/openaicompat/openaicompat.go index 39a5e684e..102967634 100644 --- a/providers/openaicompat/openaicompat.go +++ b/providers/openaicompat/openaicompat.go @@ -116,15 +116,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) - } -} - // WithUseResponsesAPI configures the provider to use the responses API for models that support it. func WithUseResponsesAPI() Option { return func(o *options) { diff --git a/providers/openrouter/openrouter.go b/providers/openrouter/openrouter.go index dfbb0345c..0b3e51d24 100644 --- a/providers/openrouter/openrouter.go +++ b/providers/openrouter/openrouter.go @@ -97,15 +97,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) - } -} - // WithObjectMode sets the object generation mode for the OpenRouter provider. // Supported modes: ObjectModeTool, ObjectModeText. // ObjectModeAuto and ObjectModeJSON are automatically converted to ObjectModeTool diff --git a/providers/openrouter/useragent_test.go b/providers/openrouter/useragent_test.go index b00acd6cb..15ff2d5e4 100644 --- a/providers/openrouter/useragent_test.go +++ b/providers/openrouter/useragent_test.go @@ -59,20 +59,6 @@ func TestUserAgent(t *testing.T) { assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"]) }) - t.Run("model segment format", func(t *testing.T) { - t.Parallel() - server, captured := newUAServer() - defer server.Close() - - p, err := New(WithAPIKey("k"), withBaseURL(server.URL), WithModelSegment("Claude 4.6 Opus")) - require.NoError(t, err) - model, _ := p.LanguageModel(t.Context(), "openai/gpt-4") - _, _ = 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("WithUserAgent wins over default", func(t *testing.T) { t.Parallel() server, captured := newUAServer() diff --git a/providers/vercel/vercel.go b/providers/vercel/vercel.go index 1dd3ee7c6..43712c1ac 100644 --- a/providers/vercel/vercel.go +++ b/providers/vercel/vercel.go @@ -104,15 +104,6 @@ func WithUserAgent(ua string) Option { } } -// WithModelSegment sets the model segment appended to the default User-Agent. -// The resulting header is "Fantasy/ ()". Pass an empty string -// to clear a previously set segment. -func WithModelSegment(model string) Option { - return func(o *options) { - o.openaiOptions = append(o.openaiOptions, openai.WithModelSegment(model)) - } -} - // WithSDKOptions sets the SDK options for the Vercel provider. func WithSDKOptions(opts ...option.RequestOption) Option { return func(o *options) { From 29f9675bfa17ca54194b308c2ec6600c985c98e8 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 14:30:40 -0500 Subject: [PATCH 05/10] chore(useragent): get version number from version.txt via go:embed --- version.go | 12 ++++++++++-- version.txt | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 version.txt diff --git a/version.go b/version.go index ff6fd6a5a..540d1d6de 100644 --- a/version.go +++ b/version.go @@ -1,4 +1,12 @@ package fantasy -// Version is the SDK version. Update this before tagging a new release. -const Version = "0.11.0" +import ( + _ "embed" + "strings" +) + +//go:embed version.txt +var version string + +// Version is the SDK version, read from version.txt. +var Version = strings.TrimSpace(version) diff --git a/version.txt b/version.txt new file mode 100644 index 000000000..d9df1bbc0 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.11.0 From 351a3c3b9a8c394655d71199a518cec4b7fb0354 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 14:34:10 -0500 Subject: [PATCH 06/10] chore(useragent): comment copyedit --- providers/internal/httpheaders/httpheaders.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/providers/internal/httpheaders/httpheaders.go b/providers/internal/httpheaders/httpheaders.go index c4b205b98..67fd6529e 100644 --- a/providers/internal/httpheaders/httpheaders.go +++ b/providers/internal/httpheaders/httpheaders.go @@ -9,12 +9,13 @@ func DefaultUserAgent(version string) string { return "Charm Fantasy/" + version } -// 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) +// ResolveHeaders returns a new header map, with a User-Agent field. // -// The input map is never mutated. +// Setting the value via WithUserAgent() takes prescidence, however the user +// agent can also be set via HTTP headers (i.e. WithHeaders()). Otherwise, the +// default user agent will be used, i.e. Charm Fantasy/0.11.0. +// +// Also note that 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 From 88b79c178af38c3a65f3aa4d2a97f1536f48304e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 15:32:03 -0500 Subject: [PATCH 07/10] docs(useragent): copy correction Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- providers/internal/httpheaders/httpheaders.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/internal/httpheaders/httpheaders.go b/providers/internal/httpheaders/httpheaders.go index 67fd6529e..0dcd54799 100644 --- a/providers/internal/httpheaders/httpheaders.go +++ b/providers/internal/httpheaders/httpheaders.go @@ -11,11 +11,11 @@ func DefaultUserAgent(version string) string { // ResolveHeaders returns a new header map, with a User-Agent field. // -// Setting the value via WithUserAgent() takes prescidence, however the user +// Setting the value via WithUserAgent() takes precedence, however the user // agent can also be set via HTTP headers (i.e. WithHeaders()). Otherwise, the // default user agent will be used, i.e. Charm Fantasy/0.11.0. // -// Also note that The input map is never mutated. +// Also note that 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 From 5b6dc4d20671141f8c12edef43172feed2f74025 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 15:39:07 -0500 Subject: [PATCH 08/10] chore(useragent): propogate UA to objects helpers, normalize headers --- object/object.go | 4 ++ providers/internal/httpheaders/httpheaders.go | 6 ++- .../internal/httpheaders/httpheaders_test.go | 38 ++++++++++++++----- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/object/object.go b/object/object.go index c62b1cbec..16c77d1cf 100644 --- a/object/object.go +++ b/object/object.go @@ -120,6 +120,7 @@ func GenerateWithTool( TopK: call.TopK, PresencePenalty: call.PresencePenalty, FrequencyPenalty: call.FrequencyPenalty, + UserAgent: call.UserAgent, ProviderOptions: call.ProviderOptions, }) if err != nil { @@ -212,6 +213,7 @@ func GenerateWithText( TopK: call.TopK, PresencePenalty: call.PresencePenalty, FrequencyPenalty: call.FrequencyPenalty, + UserAgent: call.UserAgent, ProviderOptions: call.ProviderOptions, }) if err != nil { @@ -294,6 +296,7 @@ func StreamWithTool( TopK: call.TopK, PresencePenalty: call.PresencePenalty, FrequencyPenalty: call.FrequencyPenalty, + UserAgent: call.UserAgent, ProviderOptions: call.ProviderOptions, }) if err != nil { @@ -503,6 +506,7 @@ func StreamWithText( TopK: call.TopK, PresencePenalty: call.PresencePenalty, FrequencyPenalty: call.FrequencyPenalty, + UserAgent: call.UserAgent, ProviderOptions: call.ProviderOptions, }) if err != nil { diff --git a/providers/internal/httpheaders/httpheaders.go b/providers/internal/httpheaders/httpheaders.go index 0dcd54799..0bda5f569 100644 --- a/providers/internal/httpheaders/httpheaders.go +++ b/providers/internal/httpheaders/httpheaders.go @@ -34,7 +34,11 @@ func ResolveHeaders(headers map[string]string, explicitUA, defaultUA string) map } out["User-Agent"] = explicitUA case len(uaKeys) > 0: - // keep the header-map value as-is + val := out[uaKeys[0]] + for _, k := range uaKeys { + delete(out, k) + } + out["User-Agent"] = val default: out["User-Agent"] = defaultUA } diff --git a/providers/internal/httpheaders/httpheaders_test.go b/providers/internal/httpheaders/httpheaders_test.go index 3a70293a7..d40158ab0 100644 --- a/providers/internal/httpheaders/httpheaders_test.go +++ b/providers/internal/httpheaders/httpheaders_test.go @@ -60,11 +60,13 @@ func TestResolveHeaders_Precedence(t *testing.T) { 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.Run("case-insensitive header key canonicalized 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"]) + assert.Equal(t, "from-headers", got["User-Agent"]) + _, hasLower := got["user-agent"] + assert.False(t, hasLower, "non-canonical key should be removed") }) } @@ -95,14 +97,30 @@ func TestResolveHeaders_PreservesOtherHeaders(t *testing.T) { 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") + t.Run("explicit UA removes all variants", func(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") + }) + + t.Run("no explicit UA collapses to single canonical key", func(t *testing.T) { + t.Parallel() + headers := map[string]string{ + "User-Agent": "canonical", + "user-agent": "lowercase", + } + got := ResolveHeaders(headers, "", "default") + _, hasLower := got["user-agent"] + hasCanonical := got["User-Agent"] + assert.False(t, hasLower, "non-canonical key should be removed") + assert.NotEmpty(t, hasCanonical, "canonical User-Agent key must exist") + }) } func TestCallUserAgent(t *testing.T) { From af771867d24e5b2bc6248c73decefffd57745b2a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 15:46:31 -0500 Subject: [PATCH 09/10] chore(useragent): bump version during task release --- Taskfile.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 34455b902..6ae12c518 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -37,7 +37,9 @@ tasks: - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" msg: "Git is dirty" cmds: - - git commit --allow-empty -m "{{.NEXT}}" + - echo {{trimPrefix "v" .NEXT}} > version.txt + - git add version.txt + - git commit -m "{{.NEXT}}" - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin main --follow-tags From aaea4a25bc25b3620ad48771f5a4816932be59ef Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 15:47:38 -0500 Subject: [PATCH 10/10] chore: gofumpt --- providers/anthropic/anthropic.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 10f845b9d..ca09ab3f8 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -32,12 +32,12 @@ const ( ) type options struct { - baseURL string - apiKey string - name string - headers map[string]string - userAgent string - client option.HTTPClient + baseURL string + apiKey string + name string + headers map[string]string + userAgent string + client option.HTTPClient vertexProject string vertexLocation string