fix(openrouter): don't set User-Agent

Christian Rocha created

OpenRouter rejects API calls using a custom User Agent. This workaround
skips OpenRouter when setting a user agent.

Note that we've added two new methods accordingly, as OpenRouter uses
the openai package:

openai.WithSkipUserAgent()
openai.WithLanguageModelSkipUserAgent()

Change summary

providers/openai/call_useragent.go           | 20 ++++++++++--
providers/openai/language_model.go           | 19 ++++++++++--
providers/openai/openai.go                   | 33 +++++++++++++++++++--
providers/openai/responses_language_model.go |  8 ++--
providers/openrouter/openrouter.go           |  1 
providers/openrouter/useragent_test.go       |  2 
6 files changed, 66 insertions(+), 17 deletions(-)

Detailed changes

providers/openai/call_useragent.go 🔗

@@ -7,8 +7,16 @@ import (
 )
 
 // callUARequestOptions returns per-request options that override the
-// client-level User-Agent header when the Call carries agent-level UA settings.
-func callUARequestOptions(call fantasy.Call) []option.RequestOption {
+// client-level User-Agent header when the Call carries agent-level UA
+// settings.
+//
+// When noDefaultUA is true the SDK's own User-Agent is preserved and no
+// override is applied (needed for providers like OpenRouter, which reject
+// User-Agents headers they don't expect).
+func callUARequestOptions(call fantasy.Call, noDefaultUA bool) []option.RequestOption {
+	if noDefaultUA {
+		return nil
+	}
 	if ua, ok := httpheaders.CallUserAgent(call.UserAgent); ok {
 		return []option.RequestOption{option.WithHeader("User-Agent", ua)}
 	}
@@ -16,8 +24,12 @@ func callUARequestOptions(call fantasy.Call) []option.RequestOption {
 }
 
 // objectCallUARequestOptions returns per-request options that override the
-// client-level User-Agent header when the ObjectCall carries agent-level UA settings.
-func objectCallUARequestOptions(call fantasy.ObjectCall) []option.RequestOption {
+// client-level User-Agent header when the ObjectCall carries agent-level UA
+// settings.
+func objectCallUARequestOptions(call fantasy.ObjectCall, noDefaultUA bool) []option.RequestOption {
+	if noDefaultUA {
+		return nil
+	}
 	if ua, ok := httpheaders.CallUserAgent(call.UserAgent); ok {
 		return []option.RequestOption{option.WithHeader("User-Agent", ua)}
 	}

providers/openai/language_model.go 🔗

@@ -32,6 +32,7 @@ type languageModel struct {
 	streamExtraFunc            LanguageModelStreamExtraFunc
 	streamProviderMetadataFunc LanguageModelStreamProviderMetadataFunc
 	toPromptFunc               LanguageModelToPromptFunc
+	noDefaultUserAgent         bool
 }
 
 // LanguageModelOption is a function that configures a languageModel.
@@ -86,6 +87,16 @@ func WithLanguageModelToPromptFunc(fn LanguageModelToPromptFunc) LanguageModelOp
 	}
 }
 
+// WithLanguageModelSkipUserAgent prevents per-call User-Agent overrides. This
+// exists solely for OpenRouter, which rejects User-Agent overrides.
+//
+// This function is provisional and may be removed in a future release.
+func WithLanguageModelSkipUserAgent() LanguageModelOption {
+	return func(l *languageModel) {
+		l.noDefaultUserAgent = true
+	}
+}
+
 // WithLanguageModelObjectMode sets the object generation mode.
 func WithLanguageModelObjectMode(om fantasy.ObjectMode) LanguageModelOption {
 	return func(l *languageModel) {
@@ -246,7 +257,7 @@ func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas
 	if err != nil {
 		return nil, err
 	}
-	response, err := o.client.Chat.Completions.New(ctx, *params, callUARequestOptions(call)...)
+	response, err := o.client.Chat.Completions.New(ctx, *params, callUARequestOptions(call, o.noDefaultUserAgent)...)
 	if err != nil {
 		return nil, toProviderErr(err)
 	}
@@ -314,7 +325,7 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
 		IncludeUsage: openai.Bool(true),
 	}
 
-	stream := o.client.Chat.Completions.NewStreaming(ctx, *params, callUARequestOptions(call)...)
+	stream := o.client.Chat.Completions.NewStreaming(ctx, *params, callUARequestOptions(call, o.noDefaultUserAgent)...)
 	isActiveText := false
 	toolCalls := make(map[int64]streamToolCall)
 
@@ -733,7 +744,7 @@ func (o languageModel) generateObjectWithJSONMode(ctx context.Context, call fant
 		},
 	}
 
-	response, err := o.client.Chat.Completions.New(ctx, *params, objectCallUARequestOptions(call)...)
+	response, err := o.client.Chat.Completions.New(ctx, *params, objectCallUARequestOptions(call, o.noDefaultUserAgent)...)
 	if err != nil {
 		return nil, toProviderErr(err)
 	}
@@ -817,7 +828,7 @@ func (o languageModel) streamObjectWithJSONMode(ctx context.Context, call fantas
 		IncludeUsage: openai.Bool(true),
 	}
 
-	stream := o.client.Chat.Completions.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
+	stream := o.client.Chat.Completions.NewStreaming(ctx, *params, objectCallUARequestOptions(call, o.noDefaultUserAgent)...)
 
 	return func(yield func(fantasy.ObjectStreamPart) bool) {
 		if len(warnings) > 0 {

providers/openai/openai.go 🔗

@@ -32,6 +32,7 @@ type options struct {
 	useResponsesAPI      bool
 	headers              map[string]string
 	userAgent            string
+	noDefaultUserAgent   bool
 	client               option.HTTPClient
 	sdkOptions           []option.RequestOption
 	objectMode           fantasy.ObjectMode
@@ -142,6 +143,18 @@ func WithUserAgent(ua string) Option {
 	}
 }
 
+// WithSkipUserAgent prevents the provider from setting a default
+// User-Agent header, preserving the underlying SDK's own User-Agent.
+// This is needed for providers like OpenRouter whose API behaviour depends
+// on the User-Agent matching the SDK that is making the request.
+//
+// This function is provisional and may be removed in a future release.
+func WithSkipUserAgent() Option {
+	return func(o *options) {
+		o.noDefaultUserAgent = true
+	}
+}
+
 // WithObjectMode sets the object generation mode.
 func WithObjectMode(om fantasy.ObjectMode) Option {
 	return func(o *options) {
@@ -165,10 +178,19 @@ func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.Lan
 		openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(o.options.baseURL))
 	}
 
-	defaultUA := httpheaders.DefaultUserAgent(fantasy.Version)
-	resolved := httpheaders.ResolveHeaders(o.options.headers, o.options.userAgent, defaultUA)
-	for key, value := range resolved {
-		openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
+	if o.options.noDefaultUserAgent {
+		for key, value := range o.options.headers {
+			openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
+		}
+		if o.options.userAgent != "" {
+			openaiClientOptions = append(openaiClientOptions, option.WithHeader("User-Agent", o.options.userAgent))
+		}
+	} else {
+		defaultUA := httpheaders.DefaultUserAgent(fantasy.Version)
+		resolved := httpheaders.ResolveHeaders(o.options.headers, o.options.userAgent, defaultUA)
+		for key, value := range resolved {
+			openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
+		}
 	}
 
 	if o.options.client != nil {
@@ -189,6 +211,9 @@ func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.Lan
 	}
 
 	o.options.languageModelOptions = append(o.options.languageModelOptions, WithLanguageModelObjectMode(o.options.objectMode))
+	if o.options.noDefaultUserAgent {
+		o.options.languageModelOptions = append(o.options.languageModelOptions, WithLanguageModelSkipUserAgent())
+	}
 
 	return newLanguageModel(
 		modelID,

providers/openai/responses_language_model.go 🔗

@@ -668,7 +668,7 @@ func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, opti
 
 func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
 	params, warnings := o.prepareParams(call)
-	response, err := o.client.Responses.New(ctx, *params, callUARequestOptions(call)...)
+	response, err := o.client.Responses.New(ctx, *params, callUARequestOptions(call, false)...)
 	if err != nil {
 		return nil, toProviderErr(err)
 	}
@@ -806,7 +806,7 @@ func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.Finis
 func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
 	params, warnings := o.prepareParams(call)
 
-	stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call)...)
+	stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call, false)...)
 
 	finishReason := fantasy.FinishReasonUnknown
 	var usage fantasy.Usage
@@ -1106,7 +1106,7 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context,
 	}
 
 	// Make request
-	response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...)
+	response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call, false)...)
 	if err != nil {
 		return nil, toProviderErr(err)
 	}
@@ -1216,7 +1216,7 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
 		Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
 	}
 
-	stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
+	stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call, false)...)
 
 	return func(yield func(fantasy.ObjectStreamPart) bool) {
 		if len(warnings) > 0 {

providers/openrouter/openrouter.go 🔗

@@ -31,6 +31,7 @@ func New(opts ...Option) (fantasy.Provider, error) {
 		openaiOptions: []openai.Option{
 			openai.WithName(Name),
 			openai.WithBaseURL(DefaultURL),
+			openai.WithSkipUserAgent(),
 		},
 		languageModelOptions: []openai.LanguageModelOption{
 			openai.WithLanguageModelPrepareCallFunc(languagePrepareModelCall),

providers/openrouter/useragent_test.go 🔗

@@ -56,7 +56,7 @@ func TestUserAgent(t *testing.T) {
 		_, _ = model.Generate(t.Context(), fantasy.Call{Prompt: prompt})
 
 		require.Len(t, *captured, 1)
-		assert.Equal(t, "Charm Fantasy/"+fantasy.Version, (*captured)[0]["User-Agent"])
+		assert.Equal(t, "OpenAI/Go 2.7.1", (*captured)[0]["User-Agent"])
 	})
 
 	t.Run("WithUserAgent wins over default", func(t *testing.T) {