openai.go

  1// Package openai provides an implementation of the fantasy AI SDK for OpenAI's language models.
  2package openai
  3
  4import (
  5	"cmp"
  6	"context"
  7	"maps"
  8
  9	"charm.land/fantasy"
 10	"charm.land/fantasy/providers/internal/httpheaders"
 11	"github.com/openai/openai-go/v2"
 12	"github.com/openai/openai-go/v2/option"
 13)
 14
 15const (
 16	// Name is the name of the OpenAI provider.
 17	Name = "openai"
 18	// DefaultURL is the default URL for the OpenAI API.
 19	DefaultURL = "https://api.openai.com/v1"
 20)
 21
 22type provider struct {
 23	options options
 24}
 25
 26type options struct {
 27	baseURL              string
 28	apiKey               string
 29	organization         string
 30	project              string
 31	name                 string
 32	useResponsesAPI      bool
 33	headers              map[string]string
 34	userAgent            string
 35	noDefaultUserAgent   bool
 36	client               option.HTTPClient
 37	sdkOptions           []option.RequestOption
 38	objectMode           fantasy.ObjectMode
 39	languageModelOptions []LanguageModelOption
 40}
 41
 42// Option defines a function that configures OpenAI provider options.
 43type Option = func(*options)
 44
 45// New creates a new OpenAI provider with the given options.
 46func New(opts ...Option) (fantasy.Provider, error) {
 47	providerOptions := options{
 48		headers:              map[string]string{},
 49		languageModelOptions: make([]LanguageModelOption, 0),
 50	}
 51	for _, o := range opts {
 52		o(&providerOptions)
 53	}
 54
 55	providerOptions.baseURL = cmp.Or(providerOptions.baseURL, DefaultURL)
 56	providerOptions.name = cmp.Or(providerOptions.name, Name)
 57
 58	if providerOptions.organization != "" {
 59		providerOptions.headers["OpenAi-Organization"] = providerOptions.organization
 60	}
 61	if providerOptions.project != "" {
 62		providerOptions.headers["OpenAi-Project"] = providerOptions.project
 63	}
 64
 65	return &provider{options: providerOptions}, nil
 66}
 67
 68// WithBaseURL sets the base URL for the OpenAI provider.
 69func WithBaseURL(baseURL string) Option {
 70	return func(o *options) {
 71		o.baseURL = baseURL
 72	}
 73}
 74
 75// WithAPIKey sets the API key for the OpenAI provider.
 76func WithAPIKey(apiKey string) Option {
 77	return func(o *options) {
 78		o.apiKey = apiKey
 79	}
 80}
 81
 82// WithOrganization sets the organization for the OpenAI provider.
 83func WithOrganization(organization string) Option {
 84	return func(o *options) {
 85		o.organization = organization
 86	}
 87}
 88
 89// WithProject sets the project for the OpenAI provider.
 90func WithProject(project string) Option {
 91	return func(o *options) {
 92		o.project = project
 93	}
 94}
 95
 96// WithName sets the name for the OpenAI provider.
 97func WithName(name string) Option {
 98	return func(o *options) {
 99		o.name = name
100	}
101}
102
103// WithHeaders sets the headers for the OpenAI provider.
104func WithHeaders(headers map[string]string) Option {
105	return func(o *options) {
106		maps.Copy(o.headers, headers)
107	}
108}
109
110// WithHTTPClient sets the HTTP client for the OpenAI provider.
111func WithHTTPClient(client option.HTTPClient) Option {
112	return func(o *options) {
113		o.client = client
114	}
115}
116
117// WithSDKOptions sets the SDK options for the OpenAI provider.
118func WithSDKOptions(opts ...option.RequestOption) Option {
119	return func(o *options) {
120		o.sdkOptions = append(o.sdkOptions, opts...)
121	}
122}
123
124// WithLanguageModelOptions sets the language model options for the OpenAI provider.
125func WithLanguageModelOptions(opts ...LanguageModelOption) Option {
126	return func(o *options) {
127		o.languageModelOptions = append(o.languageModelOptions, opts...)
128	}
129}
130
131// WithUseResponsesAPI configures the provider to use the responses API for models that support it.
132func WithUseResponsesAPI() Option {
133	return func(o *options) {
134		o.useResponsesAPI = true
135	}
136}
137
138// WithUserAgent sets an explicit User-Agent header, overriding the default and any
139// value set via WithHeaders.
140func WithUserAgent(ua string) Option {
141	return func(o *options) {
142		o.userAgent = ua
143	}
144}
145
146// WithSkipUserAgent prevents the provider from setting a default
147// User-Agent header, preserving the underlying SDK's own User-Agent.
148// This is needed for providers like OpenRouter whose API behaviour depends
149// on the User-Agent matching the SDK that is making the request.
150//
151// This function is provisional and may be removed in a future release.
152func WithSkipUserAgent() Option {
153	return func(o *options) {
154		o.noDefaultUserAgent = true
155	}
156}
157
158// WithObjectMode sets the object generation mode.
159func WithObjectMode(om fantasy.ObjectMode) Option {
160	return func(o *options) {
161		// not supported
162		if om == fantasy.ObjectModeJSON {
163			om = fantasy.ObjectModeAuto
164		}
165		o.objectMode = om
166	}
167}
168
169// LanguageModel implements fantasy.Provider.
170func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) {
171	openaiClientOptions := make([]option.RequestOption, 0, 5+len(o.options.headers)+len(o.options.sdkOptions))
172	openaiClientOptions = append(openaiClientOptions, option.WithMaxRetries(0))
173
174	if o.options.apiKey != "" {
175		openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(o.options.apiKey))
176	}
177	if o.options.baseURL != "" {
178		openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(o.options.baseURL))
179	}
180
181	if o.options.noDefaultUserAgent {
182		for key, value := range o.options.headers {
183			openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
184		}
185		if o.options.userAgent != "" {
186			openaiClientOptions = append(openaiClientOptions, option.WithHeader("User-Agent", o.options.userAgent))
187		}
188	} else {
189		defaultUA := httpheaders.DefaultUserAgent(fantasy.Version)
190		resolved := httpheaders.ResolveHeaders(o.options.headers, o.options.userAgent, defaultUA)
191		for key, value := range resolved {
192			openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
193		}
194	}
195
196	if o.options.client != nil {
197		openaiClientOptions = append(openaiClientOptions, option.WithHTTPClient(o.options.client))
198	}
199
200	openaiClientOptions = append(openaiClientOptions, o.options.sdkOptions...)
201
202	client := openai.NewClient(openaiClientOptions...)
203
204	if o.options.useResponsesAPI && IsResponsesModel(modelID) {
205		// Not supported for responses API
206		objectMode := o.options.objectMode
207		if objectMode == fantasy.ObjectModeJSON {
208			objectMode = fantasy.ObjectModeAuto
209		}
210		return newResponsesLanguageModel(modelID, o.options.name, client, objectMode, o.options.noDefaultUserAgent), nil
211	}
212
213	languageModelOptions := append([]LanguageModelOption{}, o.options.languageModelOptions...)
214	languageModelOptions = append(languageModelOptions, WithLanguageModelObjectMode(o.options.objectMode))
215	if o.options.noDefaultUserAgent {
216		languageModelOptions = append(languageModelOptions, WithLanguageModelSkipUserAgent())
217	}
218
219	return newLanguageModel(
220		modelID,
221		o.options.name,
222		client,
223		languageModelOptions...,
224	), nil
225}
226
227func (o *provider) Name() string {
228	return Name
229}