From 059b8880109ada7c80d1c2686d336021404e14ef Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 27 Feb 2026 11:40:17 -0500 Subject: [PATCH] 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 13a2a06d7c8701dd089e154b160cb0a5e5d9a2b6..6e574e463d3ae1193dcc752e2fa4cb3afac6873a 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 ec705cc49992c0225320690cac1ad7e78cb834d8..dcc1f517d6460d82066487a067eee7f7b958e943 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 68adc2f7a6229c297675127b20ef549601a2b261..7b42611d81a7ac5608f4034ff32ec6ee5c9920a7 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 0000000000000000000000000000000000000000..91472078118f13bd3e8fbbdd1ce35c254952932d --- /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 215021c1834ad0267f30f894b752f48f7fbafdfa..8b216f190cc2e15dc893ce296d8db8618c993991 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 0000000000000000000000000000000000000000..02b068f5a5f7663fe63fedbdd9d125ce417f21c9 --- /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 c11d988d3c7b6de9e61c6862eb747b2a2bcf856b..1830a7cc2c0efaa0580d0e134be55d6fd4161a5f 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 e4ec3b83d855b7b62f7f066ca87e3afdbb1ddc10..82e36b19c6b45c30785730c5b3319d259df00a53 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 1e289ad0f30227223b9cd02fc0bdbc8abd06f424..a66c1da9b63746c3d40142255cbbe8dae996d926 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 d696faf01bcdbae2d3f722b23593d2089d826cab..90966d0673292b2f299cf16f060041704a069ca7 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 3595a6e423ca4260c87873bd46bd950d5e33031e..39a5e684e671368ae1edd83faa1b95b6be27719c 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 bd0e700aff487efd7660180ef0aaba9d6aa6f797..dfbb0345cc32698f5769c25664eb7dc160b91842 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 0000000000000000000000000000000000000000..b00acd6cb194a7e8a36d87a5be7e0b8dc7927d74 --- /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 af87db5eba02a3f7ebea4935cb40ceb1d25d9f78..1dd3ee7c635b9b8ca78b623ccf12592617f5d4b6 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) {