diff --git a/providers/openai/call_useragent.go b/providers/openai/call_useragent.go index 4c7f2a4f7658e7e132f2accac4925450bb427e31..60527a01935aad8639a07ca4b71130059bd85411 100644 --- a/providers/openai/call_useragent.go +++ b/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)} } diff --git a/providers/openai/language_model.go b/providers/openai/language_model.go index ae3d87649185aaef2e9eec38f3e71ef39bb07216..78d23f02f426648889d29d4b97af13f0326eeacb 100644 --- a/providers/openai/language_model.go +++ b/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 { diff --git a/providers/openai/openai.go b/providers/openai/openai.go index 928f3dd287c974fb31f95e881f283808ff3b071d..dc59db10f1a143ddf93ba2169f8540b4240c74d4 100644 --- a/providers/openai/openai.go +++ b/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, diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index 090117de07ded7dd7273d8e6a8655dbfdb39ea8f..a325ed46a6c2e100aa0e895c8399ef8f863403fd 100644 --- a/providers/openai/responses_language_model.go +++ b/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 { diff --git a/providers/openrouter/openrouter.go b/providers/openrouter/openrouter.go index 0b3e51d24f360cca30583b02ee7aaada2fb3d19f..ed72e469d927411998a75de4df3297ba0e7139d4 100644 --- a/providers/openrouter/openrouter.go +++ b/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), diff --git a/providers/openrouter/useragent_test.go b/providers/openrouter/useragent_test.go index 15ff2d5e454c193918ca27b80f59c2f8eb5f21bd..eb8070bad710773d1ce8398a2aaa1b23bb4b9b44 100644 --- a/providers/openrouter/useragent_test.go +++ b/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) {