feat(useragent): configuratble provider-level user agent

Christian Rocha created

Note that the user agent defaults to Charm Fantasy/<version>, which
means we need to maintain a const.

Change summary

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 ++++++
providers/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(-)

Detailed changes

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/<version> (<agent>)". 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 {

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"])
+	})
+}

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/<version> (<agent>)". 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 {

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))
+	})
+}

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/<version> (<agent>)".
+// Otherwise, the result is "Charm Fantasy/<version>".
+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)
+}

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")
+}

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/<version> (<agent>)". 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))
 	}
 

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"])
+	})
+}

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"