feat(useragent): forward UA to provider wrappers

Christian Rocha created

Change summary

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(-)

Detailed changes

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/<version> (<agent>)". Pass an empty string
+// WithModelSegment sets the model segment appended to the default User-Agent.
+// The resulting header is "Fantasy/<version> (<model>)". 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))

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

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

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

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

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

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/<version> (<agent>)". Pass an empty string
+// WithModelSegment sets the model segment appended to the default User-Agent.
+// The resulting header is "Fantasy/<version> (<model>)". 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{}

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

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/<version> (<agent>)". Pass an empty string
+// WithModelSegment sets the model segment appended to the default User-Agent.
+// The resulting header is "Fantasy/<version> (<model>)". 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))

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

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

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

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

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