Detailed changes
@@ -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))
@@ -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")
@@ -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) {
@@ -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,
+ },
+ }
+}
@@ -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) {
@@ -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",
+ },
+ }
+}
@@ -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{}
@@ -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")
@@ -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))
@@ -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")
@@ -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) {
@@ -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
@@ -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,
+ },
+ }
+}
@@ -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) {