Detailed changes
@@ -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 {
@@ -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"])
+ })
+}
@@ -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 {
@@ -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))
+ })
+}
@@ -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)
+}
@@ -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")
+}
@@ -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))
}
@@ -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"])
+ })
+}
@@ -0,0 +1,4 @@
+package fantasy
+
+// Version is the SDK version. Update this before tagging a new release.
+const Version = "0.11.0"