From 285764f6021e5a6582e00442a5b9c84f6f95cc02 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Tue, 6 Jan 2026 18:12:17 +0000 Subject: [PATCH 01/20] init openai plugin --- go/go.mod | 1 + go/go.sum | 2 + go/plugins/internal/models.go | 10 + go/plugins/openai/openai.go | 371 ++++++++++++++++++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 go/plugins/openai/openai.go diff --git a/go/go.mod b/go/go.mod index 3472c0f4cb..bcf6887104 100644 --- a/go/go.mod +++ b/go/go.mod @@ -28,6 +28,7 @@ require ( github.com/jba/slog v0.2.0 github.com/lib/pq v1.10.9 github.com/mark3labs/mcp-go v0.29.0 + github.com/openai/openai-go/v3 v3.15.0 github.com/pgvector/pgvector-go v0.3.0 github.com/stretchr/testify v1.10.0 github.com/weaviate/weaviate v1.30.0 diff --git a/go/go.sum b/go/go.sum index e7abcc1495..7ab07f334e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -308,6 +308,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8= github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/openai/openai-go/v3 v3.15.0 h1:hk99rM7YPz+M99/5B/zOQcVwFRLLMdprVGx1vaZ8XMo= +github.com/openai/openai-go/v3 v3.15.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= diff --git a/go/plugins/internal/models.go b/go/plugins/internal/models.go index 220d1461da..c3147d1bf8 100644 --- a/go/plugins/internal/models.go +++ b/go/plugins/internal/models.go @@ -46,4 +46,14 @@ var ( Media: true, Constrained: ai.ConstrainedSupportNone, } + + // Media describes model capabilities for models with media and text input and output + Media = ai.ModelSupports{ + Multiturn: false, + Tools: false, + ToolChoice: false, + SystemRole: false, + Media: true, + Constrained: ai.ConstrainedSupportNone, + } ) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go new file mode 100644 index 0000000000..527f8f8b1e --- /dev/null +++ b/go/plugins/openai/openai.go @@ -0,0 +1,371 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package openai + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "strings" + "sync" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/internal/base" + "github.com/firebase/genkit/go/plugins/internal" + "github.com/invopop/jsonschema" + "github.com/openai/openai-go/packages/param" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/shared" +) + +const ( + openaiProvider = "openai" + openaiLabelPrefix = "OpenAI" +) + +var defaultOpenAIOpts = ai.ModelOptions{ + Supports: &internal.Multimodal, + Versions: []string{}, + Stage: ai.ModelStageUnstable, +} + +var defaultImagenOpts = ai.ModelOptions{ + Supports: &internal.Media, + Versions: []string{}, + Stage: ai.ModelStageUnstable, +} + +var defaultEmbedOpts = ai.EmbedderOptions{} + +type OpenAI struct { + mu sync.Mutex // protects concurrent access to the client and init state + initted bool // tracks weter the plugin has been initialized + client *openai.Client // openAI client used for making requests + Opts []option.RequestOption // request options for the OpenAI client + APIKey string // API key to use with the desired plugin + BaseURL string // Base URL for custom endpoints +} + +func (o *OpenAI) Name() string { + return openaiProvider +} + +func (o *OpenAI) Init(ctx context.Context) []api.Action { + if o == nil { + o = &OpenAI{} + } + o.mu.Lock() + defer o.mu.Unlock() + if o.initted { + panic("plugin already initialized") + } + + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey != "" { + o.Opts = append([]option.RequestOption{option.WithAPIKey(apiKey)}, o.Opts...) + } + + baseURL := os.Getenv("OPENAI_BASE_URL") + if baseURL != "" { + o.Opts = append([]option.RequestOption{option.WithBaseURL(baseURL)}, o.Opts...) + } + + client := openai.NewClient(o.Opts...) + o.client = &client + o.initted = true + + return []api.Action{} +} + +// DefineModel defines an unknown model with the given name. +func (o *OpenAI) DefineModel(g *genkit.Genkit, name string, opts ai.ModelOptions) (ai.Model, error) { + o.mu.Lock() + defer o.mu.Unlock() + if !o.initted { + panic("OpenAI.Init not called") + } + + // TODO: define a model, basically you need the generate() func + return nil, nil +} + +// OpenAIModel returns the [ai.Model] with the given name. +// It returns nil if the model was not previously defined. +func (o *OpenAI) OpenAIModel(g *genkit.Genkit, name string) ai.Model { + return genkit.LookupModel(g, api.NewName(openaiProvider, name)) +} + +// DefineEmbedder defines an embedder with a given name +func (o *OpenAI) DefineEmbedder(g *genkit.Genkit, name string, embedOpts *ai.EmbedderOptions) (ai.Embedder, error) { + o.mu.Lock() + defer o.mu.Unlock() + if !o.initted { + panic("OpenAI.Init not called") + } + return newEmbedder(o.client, name, embedOpts), nil +} + +// Embedder returns the [ai.Embedder] with the given name. +// It returns nil if the embedder was not previously defined. +func (o *OpenAI) Embedder(g *genkit.Genkit, name string) ai.Embedder { + return genkit.LookupEmbedder(g, name) +} + +// IsDefinedEmbedder reports whether the named [ai.Embedder] is defined by this plugin +func (o *OpenAI) IsDefinedEmbedder(g *genkit.Genkit, name string) bool { + return genkit.LookupEmbedder(g, name) != nil +} + +func (o *OpenAI) ListActions(ctx context.Context) []api.ActionDesc { + actions := []api.ActionDesc{} + models, err := listOpenAIModels(ctx, o.client) + if err != nil { + slog.Error("unable to fetch models from OpenAI API") + return nil + } + + for _, name := range models.chat { + model := newModel(o.client, name, &defaultOpenAIOpts) + if actionDef, ok := model.(api.Action); ok { + actions = append(actions, actionDef.Desc()) + } + } + for _, e := range models.embedders { + embedder := newEmbedder(o.client, e, &defaultEmbedOpts) + if actionDef, ok := embedder.(api.Action); ok { + actions = append(actions, actionDef.Desc()) + } + } + return actions +} + +func (o *OpenAI) ResolveAction(atype api.ActionType, name string) api.Action { + return nil +} + +type openaiModels struct { + chat []string // gpt, tts, o1, o2, o3... + image []string // gpt-image + video []string // sora + embedders []string // text-embedding... +} + +func listOpenAIModels(ctx context.Context, client *openai.Client) (openaiModels, error) { + models := openaiModels{} + iter := client.Models.ListAutoPaging(ctx) + for iter.Next() { + m := iter.Current() + if strings.Contains(m.ID, "sora") { + models.video = append(models.video, m.ID) + continue + } + if strings.Contains(m.ID, "image") { + models.image = append(models.image, m.ID) + continue + } + if strings.Contains(m.ID, "embedding") { + models.embedders = append(models.embedders, m.ID) + continue + } + + // NOTE: model list is just a slice of names, no extra information about them + // is available, we might select deprecated models here + // see platform.openai.com/docs/models + models.chat = append(models.chat, m.ID) + } + if err := iter.Err(); err != nil { + return openaiModels{}, err + } + + return models, nil +} + +func newEmbedder(client *openai.Client, name string, embedOpts *ai.EmbedderOptions) ai.Embedder { + return ai.NewEmbedder(api.NewName(openaiProvider, name), embedOpts, func(ctx context.Context, req *ai.EmbedRequest) (*ai.EmbedResponse, error) { + var data openai.EmbeddingNewParamsInputUnion + for _, doc := range req.Input { + for _, p := range doc.Content { + data.OfArrayOfStrings = append(data.OfArrayOfStrings, p.Text) + } + } + + params := openai.EmbeddingNewParams{ + Input: openai.EmbeddingNewParamsInputUnion(data), + Model: name, + EncodingFormat: openai.EmbeddingNewParamsEncodingFormatFloat, + } + + embeddingResp, err := client.Embeddings.New(ctx, params) + if err != nil { + return nil, err + } + + resp := &ai.EmbedResponse{} + for _, e := range embeddingResp.Data { + embedding := make([]float32, len(e.Embedding)) + for i, v := range e.Embedding { + embedding[i] = float32(v) + } + resp.Embeddings = append(resp.Embeddings, &ai.Embedding{Embedding: embedding}) + } + return resp, nil + }) +} + +func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Model { + // TODO: add support for imagen models + var config any + config = &openai.ChatCompletionNewParams{} + meta := &ai.ModelOptions{ + Label: opts.Label, + Supports: opts.Supports, + Versions: opts.Versions, + ConfigSchema: configToMap(config), + Stage: opts.Stage, + } + + fmt.Printf("meta for [%s]: %#v\n", name, meta) + fn := func( + ctx context.Context, + input *ai.ModelRequest, + cb func(context.Context, *ai.ModelResponseChunk) error, + ) (*ai.ModelResponse, error) { + switch config.(type) { + default: + return nil, nil + } + } + + return ai.NewModel(api.NewName(openaiProvider, name), meta, fn) +} + +// configToMap converts a config struct to a map[string]any +func configToMap(config any) map[string]any { + r := jsonschema.Reflector{ + DoNotReference: true, + ExpandedStruct: true, + } + schema := r.Reflect(config) + result := base.SchemaAsMap(schema) + return result +} + +func generate(ctx context.Context, client *openai.Client, model string, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error, +) (*ai.ModelResponse, error) { + req, err := toOpenAIRequest(model, input) + if err != nil { + return nil, err + } + if cb != nil { + return generateStream(ctx, client, req, cb) + } + return generateComplete(ctx, client, req, input) +} + +func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompletionNewParams, error) { + request, err := configFromRequest(input.Config) + if err != nil { + return nil, err + } + if request == nil { + request = &openai.ChatCompletionNewParams{} + } + + request.Model = model + // generate only one candidate response + if request.N == openai.Int(0) { + request.N = openai.Int(1) + } + + // Genkit primitive fields must be used instead of openai-go fields + if request.N != openai.Int(1) { + return nil, errors.New("generation of multiple candidates is not supported") + } + if !param.IsOmitted(request.ResponseFormat) { + return nil, errors.New("response format must be set using Genkit feature: ai.WithOutputType() or ai.WithOutputSchema()") + } + if request.ParallelToolCalls == openai.Bool(true) { + return nil, errors.New("only one tool call per turn is allowed") + } + + if len(input.Tools) > 0 { + tools, err := toOpenAITools(input.Tools) + if err != nil { + return nil, err + } + request.Tools = tools + } + + return request, nil +} + +func toOpenAITools(tools []*ai.ToolDefinition) ([]openai.ChatCompletionToolUnionParam, error) { + if tools == nil { + return nil, nil + } + + toolParams := make([]openai.ChatCompletionToolUnionParam, 0, len(tools)) + for _, t := range tools { + if t == nil || t.Name == "" { + continue + } + toolParams = append(toolParams, openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{ + Name: t.Name, + Description: openai.String(t.Description), + Parameters: shared.FunctionParameters(t.InputSchema), + Strict: openai.Bool(false), // TODO: implement constrained gen + })) + } + return toolParams, nil +} + +func toOpenAIToolChoice(toolChoice ai.ToolChoice, tools []*ai.ToolDefinition) (openai.ChatCompletionToolChoiceOptionUnionParam, error) { +} + +func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { + if config == nil { + return nil, nil + } + + var openaiConfig openai.ChatCompletionNewParams + switch cfg := config.(type) { + case openai.ChatCompletionNewParams: + openaiConfig = cfg + case *openai.ChatCompletionNewParams: + openaiConfig = *cfg + case map[string]any: + if err := mapToStruct(cfg, &openaiConfig); err != nil { + return nil, fmt.Errorf("failed to convert config to openai.ChatCompletionNewParams: %w", err) + } + default: + return nil, fmt.Errorf("unexpected config type: %T", config) + } + return &openaiConfig, nil +} + +// mapToStruct converts the provided map into a given struct +func mapToStruct(m map[string]any, v any) error { + jsonData, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(jsonData, v) +} From d6d1017f0e26942555bb4d6373c825af9d65f81f Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 7 Jan 2026 17:22:22 +0000 Subject: [PATCH 02/20] toToolChoice --- go/plugins/openai/openai.go | 39 +++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 527f8f8b1e..730262a66a 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -30,9 +30,9 @@ import ( "github.com/firebase/genkit/go/internal/base" "github.com/firebase/genkit/go/plugins/internal" "github.com/invopop/jsonschema" - "github.com/openai/openai-go/packages/param" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/param" "github.com/openai/openai-go/v3/shared" ) @@ -307,19 +307,20 @@ func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompleti } if len(input.Tools) > 0 { - tools, err := toOpenAITools(input.Tools) + tools, tc, err := toOpenAITools(input.Tools, input.ToolChoice) if err != nil { return nil, err } request.Tools = tools + request.ToolChoice = *tc } return request, nil } -func toOpenAITools(tools []*ai.ToolDefinition) ([]openai.ChatCompletionToolUnionParam, error) { +func toOpenAITools(tools []*ai.ToolDefinition, toolChoice ai.ToolChoice) ([]openai.ChatCompletionToolUnionParam, *openai.ChatCompletionToolChoiceOptionUnionParam, error) { if tools == nil { - return nil, nil + return nil, nil, nil } toolParams := make([]openai.ChatCompletionToolUnionParam, 0, len(tools)) @@ -334,10 +335,36 @@ func toOpenAITools(tools []*ai.ToolDefinition) ([]openai.ChatCompletionToolUnion Strict: openai.Bool(false), // TODO: implement constrained gen })) } - return toolParams, nil + + var choice openai.ChatCompletionToolChoiceOptionUnionParam + switch toolChoice { + case ai.ToolChoiceAuto, "": + choice = openai.ChatCompletionToolChoiceOptionUnionParam{ + OfAuto: param.NewOpt(string(openai.ChatCompletionToolChoiceOptionAutoAuto)), + } + case ai.ToolChoiceRequired: + choice = openai.ChatCompletionToolChoiceOptionUnionParam{ + OfAuto: param.NewOpt(string(openai.ChatCompletionToolChoiceOptionAutoRequired)), + } + case ai.ToolChoiceNone: + choice = openai.ChatCompletionToolChoiceOptionUnionParam{ + OfAuto: param.NewOpt(string(openai.ChatCompletionToolChoiceOptionAutoNone)), + } + default: + choice = openai.ToolChoiceOptionFunctionToolChoice(openai.ChatCompletionNamedToolChoiceFunctionParam{ + Name: string(toolChoice), + }) + } + + return toolParams, &choice, nil +} + +func generateStream(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + return nil, errors.New("not implemented: generateStream") } -func toOpenAIToolChoice(toolChoice ai.ToolChoice, tools []*ai.ToolDefinition) (openai.ChatCompletionToolChoiceOptionUnionParam, error) { +func generateComplete(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, input *ai.ModelRequest) (*ai.ModelResponse, error) { + return nil, errors.New("not implemented: generateComplete") } func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { From ef8ef11fe0db3a960dbace4156ea6ea8fb099d41 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 7 Jan 2026 22:24:25 +0000 Subject: [PATCH 03/20] add messages translation and test cases --- go/plugins/openai/openai.go | 200 ++++++++++++++++++++++++ go/plugins/openai/openai_test.go | 260 +++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100644 go/plugins/openai/openai_test.go diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 730262a66a..262ba384e8 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -315,9 +315,160 @@ func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompleti request.ToolChoice = *tc } + oaiMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(input.Messages)) + for _, m := range input.Messages { + switch m.Role { + case ai.RoleSystem: + oaiMessages = append(oaiMessages, toOpenAISystemMessage(m)) + + case ai.RoleModel: + msg, err := toOpenAIModelMessage(m) + if err != nil { + return nil, err + } + oaiMessages = append(oaiMessages, msg) + + case ai.RoleUser: + msg, err := toOpenAIUserMessage(m) + if err != nil { + return nil, err + } + oaiMessages = append(oaiMessages, msg) + + case ai.RoleTool: + msgs, err := toOpenAIToolMessages(m) + if err != nil { + return nil, err + } + oaiMessages = append(oaiMessages, msgs...) + + default: + return nil, fmt.Errorf("unsupported role detected: %q", m.Role) + } + } + + request.Messages = oaiMessages + return request, nil } +func toOpenAISystemMessage(m *ai.Message) openai.ChatCompletionMessageParamUnion { + return openai.SystemMessage(m.Text()) +} + +func toOpenAIModelMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion, error) { + var ( + textParts []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion + toolCalls []openai.ChatCompletionMessageToolCallUnionParam + ) + + for _, p := range m.Content { + if p.IsText() { + textParts = append(textParts, openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ + OfText: openai.TextContentPart(p.Text).OfText, + }) + } else if p.IsToolRequest() { + toolCall, err := convertToolCall(p) + if err != nil { + return openai.ChatCompletionMessageParamUnion{}, err + } + toolCalls = append(toolCalls, *toolCall) + } else { + slog.Warn("unsupported part for assistant message", "kind", p.Kind) + } + } + + msg := openai.ChatCompletionAssistantMessageParam{} + if len(textParts) > 0 { + msg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ + OfArrayOfContentParts: textParts, + } + } + if len(toolCalls) > 0 { + msg.ToolCalls = toolCalls + } + return openai.ChatCompletionMessageParamUnion{ + OfAssistant: &msg, + }, nil +} + +func toOpenAIUserMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion, error) { + msg, err := toOpenAIParts(m.Content) + if err != nil { + return openai.ChatCompletionMessageParamUnion{}, err + } + userParts := make([]openai.ChatCompletionContentPartUnionParam, len(msg)) + for i, p := range msg { + userParts[i] = *p + } + return openai.ChatCompletionMessageParamUnion{ + OfUser: &openai.ChatCompletionUserMessageParam{ + Content: openai.ChatCompletionUserMessageParamContentUnion{ + OfArrayOfContentParts: userParts, + }, + }, + }, nil +} + +func toOpenAIToolMessages(m *ai.Message) ([]openai.ChatCompletionMessageParamUnion, error) { + var msgs []openai.ChatCompletionMessageParamUnion + for _, p := range m.Content { + if p.IsToolResponse() { + content, err := json.Marshal(p.ToolResponse.Output) + if err != nil { + return nil, fmt.Errorf("failed to marshal tool response output: %w", err) + } + msgs = append(msgs, openai.ChatCompletionMessageParamUnion{ + OfTool: &openai.ChatCompletionToolMessageParam{ + ToolCallID: p.ToolResponse.Ref, + Content: openai.ChatCompletionToolMessageParamContentUnion{ + OfString: param.NewOpt(string(content)), + }, + }, + }) + } + } + return msgs, nil +} + +// toOpenAIParts converts a slice of [ai.Part] to a slice of [openai.ChatCompletionMessageParamUnion] +func toOpenAIParts(parts []*ai.Part) ([]*openai.ChatCompletionContentPartUnionParam, error) { + resp := make([]*openai.ChatCompletionContentPartUnionParam, 0, len(parts)) + for _, p := range parts { + part, err := toOpenAIPart(p) + if err != nil { + return nil, err + } + resp = append(resp, part) + } + return resp, nil +} + +func toOpenAIPart(p *ai.Part) (*openai.ChatCompletionContentPartUnionParam, error) { + var m openai.ChatCompletionContentPartUnionParam + if p == nil { + return nil, fmt.Errorf("empty part detected") + } + + switch { + case p.IsText(): + m = openai.TextContentPart(p.Text) + case p.IsImage(), p.IsMedia(): + m = openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{ + URL: p.Text, + }) + case p.IsData(): + m = openai.FileContentPart(openai.ChatCompletionContentPartFileFileParam{ + FileData: openai.String(p.Text), + }) + default: + return nil, fmt.Errorf("unsupported part kind: %v", p.Kind) + } + + return &m, nil +} + +// toOpenAITools converts a slice of [ai.ToolDefinition] and [ai.ToolChoice] to their appropriate openAI types func toOpenAITools(tools []*ai.ToolDefinition, toolChoice ai.ToolChoice) ([]openai.ChatCompletionToolUnionParam, *openai.ChatCompletionToolChoiceOptionUnionParam, error) { if tools == nil { return nil, nil, nil @@ -388,6 +539,55 @@ func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { return &openaiConfig, nil } +func convertToolCalls(content []*ai.Part) ([]openai.ChatCompletionMessageToolCallUnionParam, error) { + var toolCalls []openai.ChatCompletionMessageToolCallUnionParam + for _, p := range content { + if !p.IsToolRequest() { + continue + } + toolCall, err := convertToolCall(p) + if err != nil { + return nil, err + } + toolCalls = append(toolCalls, *toolCall) + } + return toolCalls, nil +} + +func convertToolCall(part *ai.Part) (*openai.ChatCompletionMessageToolCallUnionParam, error) { + toolCallID := part.ToolRequest.Ref + if toolCallID == "" { + toolCallID = part.ToolRequest.Name + } + + param := &openai.ChatCompletionMessageToolCallUnionParam{ + OfFunction: &openai.ChatCompletionMessageFunctionToolCallParam{ + ID: (toolCallID), + Function: (openai.ChatCompletionMessageFunctionToolCallFunctionParam{ + Name: (part.ToolRequest.Name), + }), + }, + } + + args, err := anyToJSONString(part.ToolRequest.Input) + if err != nil { + return nil, err + } + if part.ToolRequest.Input != nil { + param.OfFunction.Function.Arguments = args + } + + return param, nil +} + +func anyToJSONString(data any) (string, error) { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal any to JSON string: data, %#v %w", data, err) + } + return string(jsonBytes), nil +} + // mapToStruct converts the provided map into a given struct func mapToStruct(m map[string]any, v any) error { jsonData, err := json.Marshal(m) diff --git a/go/plugins/openai/openai_test.go b/go/plugins/openai/openai_test.go new file mode 100644 index 0000000000..52071c7335 --- /dev/null +++ b/go/plugins/openai/openai_test.go @@ -0,0 +1,260 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package openai + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/firebase/genkit/go/ai" + "github.com/openai/openai-go/v3" +) + +func TestToOpenAISystemMessage(t *testing.T) { + tests := []struct { + name string + msg *ai.Message + want string + }{ + { + name: "basic system message", + msg: &ai.Message{ + Role: ai.RoleSystem, + Content: []*ai.Part{ai.NewTextPart("system instruction")}, + }, + want: "system instruction", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotUnion := toOpenAISystemMessage(tc.msg) + + if gotUnion.OfSystem == nil { + t.Fatalf("toOpenAISystemMessage() returned union with nil OfSystem") + } + val := gotUnion.OfSystem.Content.OfString.Value + if val != tc.want { + t.Errorf("toOpenAISystemMessage() content = %q, want %q", val, tc.want) + } + }) + } +} + +func TestToOpenAIModelMessage(t *testing.T) { + tests := []struct { + name string + msg *ai.Message + checkFunc func(*testing.T, openai.ChatCompletionMessageParamUnion) + }{ + { + name: "text message", + msg: &ai.Message{ + Role: ai.RoleModel, + Content: []*ai.Part{ai.NewTextPart("model response")}, + }, + checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { + if gotUnion.OfAssistant == nil { + t.Fatalf("expected OfAssistant to be non-nil") + } + parts := gotUnion.OfAssistant.Content.OfArrayOfContentParts + if len(parts) != 1 { + t.Fatalf("got %d content parts, want 1", len(parts)) + } + textPart := parts[0].OfText + if got, want := textPart.Text, "model response"; got != want { + t.Errorf("content = %q, want %q", got, want) + } + }, + }, + { + name: "tool call message", + msg: &ai.Message{ + Role: ai.RoleModel, + Content: []*ai.Part{ + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "myTool", + Ref: "call_123", + Input: map[string]any{"arg": "value"}, + }), + }, + }, + checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { + if gotUnion.OfAssistant == nil { + t.Fatalf("expected OfAssistant to be non-nil") + } + toolCalls := gotUnion.OfAssistant.ToolCalls + if len(toolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(toolCalls)) + } + + if toolCalls[0].OfFunction == nil { + t.Fatalf("expected Function tool call") + } + fnCall := toolCalls[0].OfFunction + + if got, want := fnCall.ID, "call_123"; got != want { + t.Errorf("tool call ID = %q, want %q", got, want) + } + if got, want := fnCall.Function.Name, "myTool"; got != want { + t.Errorf("function name = %q, want %q", got, want) + } + + var gotArgs map[string]any + if err := json.Unmarshal([]byte(fnCall.Function.Arguments), &gotArgs); err != nil { + t.Fatalf("failed to unmarshal arguments: %v", err) + } + wantArgs := map[string]any{"arg": "value"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Errorf("arguments = %v, want %v", gotArgs, wantArgs) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := toOpenAIModelMessage(tc.msg) + if err != nil { + t.Fatalf("toOpenAIModelMessage() unexpected error: %v", err) + } + tc.checkFunc(t, got) + }) + } +} + +func TestToOpenAIUserMessage(t *testing.T) { + tests := []struct { + name string + msg *ai.Message + checkFunc func(*testing.T, openai.ChatCompletionMessageParamUnion) + }{ + { + name: "text message", + msg: &ai.Message{ + Role: ai.RoleUser, + Content: []*ai.Part{ai.NewTextPart("user query")}, + }, + checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { + if gotUnion.OfUser == nil { + t.Fatalf("expected OfUser to be non-nil") + } + parts := gotUnion.OfUser.Content.OfArrayOfContentParts + if len(parts) != 1 { + t.Fatalf("got %d content parts, want 1", len(parts)) + } + textPart := parts[0].OfText + if textPart == nil { + t.Fatalf("expected Text content part") + } + if got, want := textPart.Text, "user query"; got != want { + t.Errorf("content = %q, want %q", got, want) + } + }, + }, + { + name: "image message", + msg: &ai.Message{ + Role: ai.RoleUser, + Content: []*ai.Part{ + ai.NewMediaPart("image/png", "http://example.com/image.png"), + }, + }, + checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { + if gotUnion.OfUser == nil { + t.Fatalf("expected OfUser to be non-nil") + } + parts := gotUnion.OfUser.Content.OfArrayOfContentParts + if len(parts) != 1 { + t.Fatalf("got %d content parts, want 1", len(parts)) + } + imagePart := parts[0].OfImageURL + if imagePart == nil { + t.Fatalf("expected Image content part") + } + if got, want := imagePart.ImageURL.URL, "http://example.com/image.png"; got != want { + t.Errorf("image URL = %q, want %q", got, want) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := toOpenAIUserMessage(tc.msg) + if err != nil { + t.Fatalf("toOpenAIUserMessage() unexpected error: %v", err) + } + tc.checkFunc(t, got) + }) + } +} + +func TestToOpenAIToolMessages(t *testing.T) { + tests := []struct { + name string + msg *ai.Message + checkFunc func(*testing.T, []openai.ChatCompletionMessageParamUnion) + }{ + { + name: "tool response", + msg: &ai.Message{ + Role: ai.RoleTool, + Content: []*ai.Part{ + ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "myTool", + Ref: "call_123", + Output: map[string]any{"result": "success"}, + }), + }, + }, + checkFunc: func(t *testing.T, gotMsgs []openai.ChatCompletionMessageParamUnion) { + if len(gotMsgs) != 1 { + t.Fatalf("got %d messages, want 1", len(gotMsgs)) + } + if gotMsgs[0].OfTool == nil { + t.Fatalf("expected OfTool to be non-nil") + } + toolMsg := gotMsgs[0].OfTool + if got, want := toolMsg.ToolCallID, "call_123"; got != want { + t.Errorf("tool call ID = %q, want %q", got, want) + } + + // Content is Union. Expecting OfString. + content := toolMsg.Content.OfString.Value + + var gotOutput map[string]any + if err := json.Unmarshal([]byte(content), &gotOutput); err != nil { + t.Fatalf("failed to unmarshal output: %v", err) + } + wantOutput := map[string]any{"result": "success"} + if !reflect.DeepEqual(gotOutput, wantOutput) { + t.Errorf("output = %v, want %v", gotOutput, wantOutput) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := toOpenAIToolMessages(tc.msg) + if err != nil { + t.Fatalf("toOpenAIToolMessages() unexpected error: %v", err) + } + tc.checkFunc(t, got) + }) + } +} From 9a33b3c4c40be383d56ceb88abf4bbe27d9a17ef Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 9 Jan 2026 01:22:33 +0000 Subject: [PATCH 04/20] add generate functions --- go/plugins/openai/openai.go | 221 ++++++++++++++++++++++++++++++++---- 1 file changed, 198 insertions(+), 23 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 262ba384e8..8ae3f6d57c 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -158,6 +158,27 @@ func (o *OpenAI) ListActions(ctx context.Context) []api.ActionDesc { } func (o *OpenAI) ResolveAction(atype api.ActionType, name string) api.Action { + switch atype { + case api.ActionTypeEmbedder: + return newEmbedder(o.client, name, &ai.EmbedderOptions{}).(api.Action) + case api.ActionTypeModel: + var supports *ai.ModelSupports + var config any + + switch { + // TODO: add image and video models + default: + supports = &internal.Multimodal + config = &openai.ChatCompletionNewParams{} + } + return newModel(o.client, name, &ai.ModelOptions{ + Label: fmt.Sprintf("%s - %s", openaiLabelPrefix, name), + Stage: ai.ModelStageStable, + Versions: []string{}, + Supports: supports, + ConfigSchema: configToMap(config), + }).(api.Action) + } return nil } @@ -230,8 +251,8 @@ func newEmbedder(client *openai.Client, name string, embedOpts *ai.EmbedderOptio }) } +// newModel creates a new model without registering it in the registry func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Model { - // TODO: add support for imagen models var config any config = &openai.ChatCompletionNewParams{} meta := &ai.ModelOptions{ @@ -242,15 +263,17 @@ func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Mode Stage: opts.Stage, } - fmt.Printf("meta for [%s]: %#v\n", name, meta) fn := func( ctx context.Context, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error, ) (*ai.ModelResponse, error) { switch config.(type) { + // TODO: add support for imagen and video + case *openai.ChatCompletionNewParams: + return generate(ctx, client, name, input, cb) default: - return nil, nil + return generate(ctx, client, name, input, cb) } } @@ -274,10 +297,22 @@ func generate(ctx context.Context, client *openai.Client, model string, input *a if err != nil { return nil, err } + + // stream mode if cb != nil { - return generateStream(ctx, client, req, cb) + resp, err := generateStream(ctx, client, req, input, cb) + if err != nil { + return nil, err + } + return resp, nil + } - return generateComplete(ctx, client, req, input) + + resp, err := generateComplete(ctx, client, req, input) + if err != nil { + return nil, err + } + return resp, nil } func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompletionNewParams, error) { @@ -510,12 +545,167 @@ func toOpenAITools(tools []*ai.ToolDefinition, toolChoice ai.ToolChoice) ([]open return toolParams, &choice, nil } -func generateStream(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { - return nil, errors.New("not implemented: generateStream") +func generateStream(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + if req == nil { + return nil, fmt.Errorf("empty request detected") + } + stream := client.Chat.Completions.NewStreaming(ctx, *req) + defer stream.Close() + + acc := &openai.ChatCompletionAccumulator{} + + for stream.Next() { + chunk := stream.Current() + // last chunk won't have choices but usage stats + acc.AddChunk(chunk) + if len(chunk.Choices) == 0 { + continue + } + + callbackChunk := &ai.ModelResponseChunk{} + chunkContent := chunk.Choices[0].Delta.Content + if chunkContent != "" { + callbackChunk.Content = append(callbackChunk.Content, ai.NewTextPart(chunkContent)) + } + + for _, toolCall := range chunk.Choices[0].Delta.ToolCalls { + if toolCall.Function.Name != "" || toolCall.Function.Arguments != "" { + callbackChunk.Content = append(callbackChunk.Content, + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: toolCall.Function.Name, + Input: toolCall.Function.Arguments, + Ref: toolCall.ID, + })) + } + } + if len(callbackChunk.Content) > 0 { + if err := cb(ctx, callbackChunk); err != nil { + return nil, fmt.Errorf("callback error: %w", err) + } + } + } + if err := stream.Err(); err != nil { + return nil, fmt.Errorf("stream error: %w", err) + } + + resp, err := translateResponse(&acc.ChatCompletion) + if err != nil { + return nil, err + } + resp.Request = input + return resp, nil } func generateComplete(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, input *ai.ModelRequest) (*ai.ModelResponse, error) { - return nil, errors.New("not implemented: generateComplete") + if req == nil { + return nil, fmt.Errorf("empty request detected") + } + c, err := client.Chat.Completions.New(ctx, *req) + if err != nil { + return nil, err + } + + resp, err := translateResponse(c) + if err != nil { + return nil, err + } + + resp.Request = input + return resp, nil +} + +func translateResponse(c *openai.ChatCompletion) (*ai.ModelResponse, error) { + if len(c.Choices) == 0 { + return nil, fmt.Errorf("nothing to translate, empty response") + } + + // by default the client will always generate 1 candidate + candidate := c.Choices[0] + usage := &ai.GenerationUsage{ + InputTokens: int(c.Usage.PromptTokens), + OutputTokens: int(c.Usage.CompletionTokens), + TotalTokens: int(c.Usage.TotalTokens), + } + + rt := c.Usage.CompletionTokensDetails.ReasoningTokens + if rt > 0 { + usage.ThoughtsTokens = int(rt) + } + ct := c.Usage.PromptTokensDetails.CachedTokens + if ct > 0 { + usage.CachedContentTokens = int(ct) + } + + // custom OpenAI usage stats + if usage.Custom == nil { + usage.Custom = make(map[string]float64) + } + at := c.Usage.CompletionTokensDetails.AudioTokens + if at > 0 { + usage.Custom["audioTokens"] = float64(at) + } + apt := c.Usage.CompletionTokensDetails.AcceptedPredictionTokens + if apt > 0 { + usage.Custom["acceptedPredictionTokens"] = float64(apt) + } + rpt := c.Usage.CompletionTokensDetails.RejectedPredictionTokens + if rpt > 0 { + usage.Custom["rejectedPredictionTokens"] = float64(rpt) + } + + resp := &ai.ModelResponse{} + + switch candidate.FinishReason { + case "stop", "tool_calls": + resp.FinishReason = ai.FinishReasonStop + case "length": + resp.FinishReason = ai.FinishReasonLength + case "content_filter": + resp.FinishReason = ai.FinishReasonBlocked + case "function_call": + resp.FinishReason = ai.FinishReasonOther + default: + resp.FinishReason = ai.FinishReasonUnknown + } + + if candidate.Message.Refusal != "" { + resp.FinishMessage = candidate.Message.Refusal + resp.FinishReason = ai.FinishReasonBlocked + } + + // candidate custom fields + if resp.Custom == nil { + custom := map[string]any{ + "id": c.ID, + "model": c.Model, + "created": c.Created, + } + + type Citation struct { + EndIndex int64 `json:"end_index"` + StartIndex int64 `json:"start_index"` + Title string `json:"title"` + URL string `json:"url"` + } + + citations := []Citation{} + // citations for web_search tool + for _, a := range candidate.Message.Annotations { + citations = append(citations, Citation{ + EndIndex: a.URLCitation.EndIndex, + StartIndex: a.URLCitation.StartIndex, + Title: a.URLCitation.Title, + URL: a.URLCitation.URL, + }) + } + custom["citations"] = citations + resp.Custom = custom + } + + resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(candidate.Message.Content)) + resp.Usage = usage + + return resp, nil } func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { @@ -539,21 +729,6 @@ func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { return &openaiConfig, nil } -func convertToolCalls(content []*ai.Part) ([]openai.ChatCompletionMessageToolCallUnionParam, error) { - var toolCalls []openai.ChatCompletionMessageToolCallUnionParam - for _, p := range content { - if !p.IsToolRequest() { - continue - } - toolCall, err := convertToolCall(p) - if err != nil { - return nil, err - } - toolCalls = append(toolCalls, *toolCall) - } - return toolCalls, nil -} - func convertToolCall(part *ai.Part) (*openai.ChatCompletionMessageToolCallUnionParam, error) { toolCallID := part.ToolRequest.Ref if toolCallID == "" { From a15f83c3831281cc1ff43554776166d7746f99f5 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 9 Jan 2026 01:41:17 +0000 Subject: [PATCH 05/20] docs --- go/plugins/openai/openai.go | 45 ++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 8ae3f6d57c..61d88c623d 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -96,15 +96,20 @@ func (o *OpenAI) Init(ctx context.Context) []api.Action { } // DefineModel defines an unknown model with the given name. -func (o *OpenAI) DefineModel(g *genkit.Genkit, name string, opts ai.ModelOptions) (ai.Model, error) { +func (o *OpenAI) DefineModel(g *genkit.Genkit, name string, opts *ai.ModelOptions) (ai.Model, error) { o.mu.Lock() defer o.mu.Unlock() if !o.initted { panic("OpenAI.Init not called") } + if name == "" { + return nil, fmt.Errorf("OpenAI.DefineModel: called with empty model name") + } - // TODO: define a model, basically you need the generate() func - return nil, nil + if opts == nil { + return nil, fmt.Errorf("OpenAI.DefineModel: called with unknown model options") + } + return newModel(o.client, name, opts), nil } // OpenAIModel returns the [ai.Model] with the given name. @@ -134,6 +139,7 @@ func (o *OpenAI) IsDefinedEmbedder(g *genkit.Genkit, name string) bool { return genkit.LookupEmbedder(g, name) != nil } +// ListActions lists all the actions supported by the OpenAI plugin. func (o *OpenAI) ListActions(ctx context.Context) []api.ActionDesc { actions := []api.ActionDesc{} models, err := listOpenAIModels(ctx, o.client) @@ -157,6 +163,7 @@ func (o *OpenAI) ListActions(ctx context.Context) []api.ActionDesc { return actions } +// ResolveAction resolves an action with the given name. func (o *OpenAI) ResolveAction(atype api.ActionType, name string) api.Action { switch atype { case api.ActionTypeEmbedder: @@ -182,6 +189,7 @@ func (o *OpenAI) ResolveAction(atype api.ActionType, name string) api.Action { return nil } +// openaiModels contains the collection of supported OpenAI models type openaiModels struct { chat []string // gpt, tts, o1, o2, o3... image []string // gpt-image @@ -189,6 +197,15 @@ type openaiModels struct { embedders []string // text-embedding... } +// listOpenAIModels returns a list of models available in the OpenAI API +// The returned struct is a filtered list of models based on plain string comparisons: +// chat: gpt, tts, o1, o2, o3... +// image: gpt-image +// video: sora +// embedders: text-embedding +// NOTE: the returned list from the SDK is just a plain slice of model names. +// No extra information about the model stage or type is provided. +// See: platform.openai.com/docs/models func listOpenAIModels(ctx context.Context, client *openai.Client) (openaiModels, error) { models := openaiModels{} iter := client.Models.ListAutoPaging(ctx) @@ -206,10 +223,6 @@ func listOpenAIModels(ctx context.Context, client *openai.Client) (openaiModels, models.embedders = append(models.embedders, m.ID) continue } - - // NOTE: model list is just a slice of names, no extra information about them - // is available, we might select deprecated models here - // see platform.openai.com/docs/models models.chat = append(models.chat, m.ID) } if err := iter.Err(); err != nil { @@ -219,6 +232,7 @@ func listOpenAIModels(ctx context.Context, client *openai.Client) (openaiModels, return models, nil } +// newEmbedder creates a new embedder without registering it func newEmbedder(client *openai.Client, name string, embedOpts *ai.EmbedderOptions) ai.Embedder { return ai.NewEmbedder(api.NewName(openaiProvider, name), embedOpts, func(ctx context.Context, req *ai.EmbedRequest) (*ai.EmbedResponse, error) { var data openai.EmbeddingNewParamsInputUnion @@ -291,6 +305,7 @@ func configToMap(config any) map[string]any { return result } +// generate is the entry point function to request content generation to the OpenAI client func generate(ctx context.Context, client *openai.Client, model string, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error, ) (*ai.ModelResponse, error) { req, err := toOpenAIRequest(model, input) @@ -315,6 +330,7 @@ func generate(ctx context.Context, client *openai.Client, model string, input *a return resp, nil } +// toOpenAIRequest translates an [ai.ModelRequest] into [openai.ChatCompletionNewParams] func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompletionNewParams, error) { request, err := configFromRequest(input.Config) if err != nil { @@ -387,10 +403,14 @@ func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompleti return request, nil } +// toOpenAISystemMessage translates a system message contained in [ai.Message] into +// [openai.ChatCompletionMessageParamUnion] func toOpenAISystemMessage(m *ai.Message) openai.ChatCompletionMessageParamUnion { return openai.SystemMessage(m.Text()) } +// toOpenAIModelMessage translates a model message contained in [ai.Message] into +// [openai.ChatCompletionMessageParamUnion] func toOpenAIModelMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion, error) { var ( textParts []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion @@ -427,6 +447,7 @@ func toOpenAIModelMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion }, nil } +// toOpenAIUserMessage translates a user message contained in [ai.Message] into [openai.ChatCompletionMessageParamUnion] func toOpenAIUserMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion, error) { msg, err := toOpenAIParts(m.Content) if err != nil { @@ -445,6 +466,7 @@ func toOpenAIUserMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion, }, nil } +// toOpenAIToolMessages translates an [ai.Message] into a slice of [openai.ChatCompletionMessageParamUnion] func toOpenAIToolMessages(m *ai.Message) ([]openai.ChatCompletionMessageParamUnion, error) { var msgs []openai.ChatCompletionMessageParamUnion for _, p := range m.Content { @@ -479,6 +501,7 @@ func toOpenAIParts(parts []*ai.Part) ([]*openai.ChatCompletionContentPartUnionPa return resp, nil } +// toOpenAIPart translates an [ai.Part] into [openai.ChatCompletionContentPartUnionParam] func toOpenAIPart(p *ai.Part) (*openai.ChatCompletionContentPartUnionParam, error) { var m openai.ChatCompletionContentPartUnionParam if p == nil { @@ -545,6 +568,7 @@ func toOpenAITools(tools []*ai.ToolDefinition, toolChoice ai.ToolChoice) ([]open return toolParams, &choice, nil } +// generateStream starts a new chat streaming completion in the OpenAI client func generateStream(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { if req == nil { return nil, fmt.Errorf("empty request detected") @@ -596,6 +620,7 @@ func generateStream(ctx context.Context, client *openai.Client, req *openai.Chat return resp, nil } +// generateComplete starts a new chat completion in the OpenAI client func generateComplete(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, input *ai.ModelRequest) (*ai.ModelResponse, error) { if req == nil { return nil, fmt.Errorf("empty request detected") @@ -614,6 +639,7 @@ func generateComplete(ctx context.Context, client *openai.Client, req *openai.Ch return resp, nil } +// translateResponse translates an [openai.ChatCompletion] into an [ai.ModelResponse] func translateResponse(c *openai.ChatCompletion) (*ai.ModelResponse, error) { if len(c.Choices) == 0 { return nil, fmt.Errorf("nothing to translate, empty response") @@ -708,6 +734,7 @@ func translateResponse(c *openai.ChatCompletion) (*ai.ModelResponse, error) { return resp, nil } +// configFromRequest casts the given configuration into [openai.ChatCompletionNewParams] func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { if config == nil { return nil, nil @@ -729,6 +756,7 @@ func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { return &openaiConfig, nil } +// convertToolCall translates a tool part in [ai.Part] into a [openai.ChatCompletionMessageToolCallUnionParam] func convertToolCall(part *ai.Part) (*openai.ChatCompletionMessageToolCallUnionParam, error) { toolCallID := part.ToolRequest.Ref if toolCallID == "" { @@ -755,10 +783,11 @@ func convertToolCall(part *ai.Part) (*openai.ChatCompletionMessageToolCallUnionP return param, nil } +// anyToJSONString converts a stream of bytes to a JSON string func anyToJSONString(data any) (string, error) { jsonBytes, err := json.Marshal(data) if err != nil { - return "", fmt.Errorf("failed to marshal any to JSON string: data, %#v %w", data, err) + return "", fmt.Errorf("failed to marshal any to JSON string: %w", err) } return string(jsonBytes), nil } From 7d4c46fcd16949803f665bb623bc37e4c5bf02af Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 9 Jan 2026 16:33:17 +0000 Subject: [PATCH 06/20] test: config to schema --- go/plugins/openai/openai.go | 48 ++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 61d88c623d..d553ea9106 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -21,6 +21,7 @@ import ( "fmt" "log/slog" "os" + "reflect" "strings" "sync" @@ -297,11 +298,38 @@ func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Mode // configToMap converts a config struct to a map[string]any func configToMap(config any) map[string]any { r := jsonschema.Reflector{ - DoNotReference: true, - ExpandedStruct: true, + DoNotReference: false, + AllowAdditionalProperties: false, + RequiredFromJSONSchemaTags: true, + } + + r.Mapper = func(r reflect.Type) *jsonschema.Schema { + if r.Name() == "Opt[float64]" { + return &jsonschema.Schema{ + Type: "number", + } + } + if r.Name() == "Opt[int64]" { + return &jsonschema.Schema{ + Type: "integer", + } + } + if r.Name() == "Opt[string]" { + return &jsonschema.Schema{ + Type: "string", + } + } + if r.Name() == "Opt[bool]" { + return &jsonschema.Schema{ + Type: "boolean", + } + } + return nil } schema := r.Reflect(config) result := base.SchemaAsMap(schema) + + fmt.Printf("result: %#v\n", result) return result } @@ -341,15 +369,8 @@ func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompleti } request.Model = model - // generate only one candidate response - if request.N == openai.Int(0) { - request.N = openai.Int(1) - } + request.N = openai.Int(1) - // Genkit primitive fields must be used instead of openai-go fields - if request.N != openai.Int(1) { - return nil, errors.New("generation of multiple candidates is not supported") - } if !param.IsOmitted(request.ResponseFormat) { return nil, errors.New("response format must be set using Genkit feature: ai.WithOutputType() or ai.WithOutputSchema()") } @@ -679,7 +700,12 @@ func translateResponse(c *openai.ChatCompletion) (*ai.ModelResponse, error) { usage.Custom["rejectedPredictionTokens"] = float64(rpt) } - resp := &ai.ModelResponse{} + resp := &ai.ModelResponse{ + Message: &ai.Message{ + Role: ai.RoleModel, + Content: make([]*ai.Part, 0), + }, + } switch candidate.FinishReason { case "stop", "tool_calls": From cec8cad66fa68888707065998c2f7912bb4c6661 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 9 Jan 2026 21:35:20 +0000 Subject: [PATCH 07/20] fix schema parsing --- go/plugins/openai/openai.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index d553ea9106..b01914c35c 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -298,8 +298,9 @@ func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Mode // configToMap converts a config struct to a map[string]any func configToMap(config any) map[string]any { r := jsonschema.Reflector{ - DoNotReference: false, + DoNotReference: true, AllowAdditionalProperties: false, + ExpandedStruct: true, RequiredFromJSONSchemaTags: true, } @@ -329,7 +330,6 @@ func configToMap(config any) map[string]any { schema := r.Reflect(config) result := base.SchemaAsMap(schema) - fmt.Printf("result: %#v\n", result) return result } From ebb59ba022528d3b31951e365550e2b160fc63d3 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Fri, 9 Jan 2026 23:53:41 +0000 Subject: [PATCH 08/20] fix usage tokens and tool calls --- go/plugins/openai/openai.go | 39 +++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index b01914c35c..986987f12a 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -113,12 +113,17 @@ func (o *OpenAI) DefineModel(g *genkit.Genkit, name string, opts *ai.ModelOption return newModel(o.client, name, opts), nil } -// OpenAIModel returns the [ai.Model] with the given name. +// Model returns the [ai.Model] with the given name. // It returns nil if the model was not previously defined. -func (o *OpenAI) OpenAIModel(g *genkit.Genkit, name string) ai.Model { +func Model(g *genkit.Genkit, name string) ai.Model { return genkit.LookupModel(g, api.NewName(openaiProvider, name)) } +// IsDefinedModel reports whether the named [ai.Model] is defined by this plugin +func IsDefinedModel(g *genkit.Genkit, name string) bool { + return genkit.LookupModel(g, name) != nil +} + // DefineEmbedder defines an embedder with a given name func (o *OpenAI) DefineEmbedder(g *genkit.Genkit, name string, embedOpts *ai.EmbedderOptions) (ai.Embedder, error) { o.mu.Lock() @@ -131,12 +136,12 @@ func (o *OpenAI) DefineEmbedder(g *genkit.Genkit, name string, embedOpts *ai.Emb // Embedder returns the [ai.Embedder] with the given name. // It returns nil if the embedder was not previously defined. -func (o *OpenAI) Embedder(g *genkit.Genkit, name string) ai.Embedder { +func Embedder(g *genkit.Genkit, name string) ai.Embedder { return genkit.LookupEmbedder(g, name) } // IsDefinedEmbedder reports whether the named [ai.Embedder] is defined by this plugin -func (o *OpenAI) IsDefinedEmbedder(g *genkit.Genkit, name string) bool { +func IsDefinedEmbedder(g *genkit.Genkit, name string) bool { return genkit.LookupEmbedder(g, name) != nil } @@ -594,6 +599,10 @@ func generateStream(ctx context.Context, client *openai.Client, req *openai.Chat if req == nil { return nil, fmt.Errorf("empty request detected") } + + // include usage stats by default, otherwise token count will always be zero + req.StreamOptions.IncludeUsage = openai.Bool(true) + stream := client.Chat.Completions.NewStreaming(ctx, *req) defer stream.Close() @@ -754,6 +763,19 @@ func translateResponse(c *openai.ChatCompletion) (*ai.ModelResponse, error) { resp.Custom = custom } + // Add tool calls + for _, toolCall := range candidate.Message.ToolCalls { + args, err := jsonStringToMap(toolCall.Function.Arguments) + if err != nil { + return nil, fmt.Errorf("could not parse tool args: %w", err) + } + resp.Message.Content = append(resp.Message.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: toolCall.ID, + Name: toolCall.Function.Name, + Input: args, + })) + } + resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(candidate.Message.Content)) resp.Usage = usage @@ -826,3 +848,12 @@ func mapToStruct(m map[string]any, v any) error { } return json.Unmarshal(jsonData, v) } + +// jsonStringToMap translates a JSON string into a map +func jsonStringToMap(jsonString string) (map[string]any, error) { + var result map[string]any + if err := json.Unmarshal([]byte(jsonString), &result); err != nil { + return nil, fmt.Errorf("unmarshal failed to parse json string %s: %w", jsonString, err) + } + return result, nil +} From c9bd210503186b472f55e9fbe74c9c383a188d21 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Mon, 12 Jan 2026 21:56:39 +0000 Subject: [PATCH 09/20] fix stream and conv history --- go/plugins/openai/openai.go | 615 ++++++++++---------------- go/plugins/openai/openai_live_test.go | 395 +++++++++++++++++ go/plugins/openai/openai_test.go | 399 ++++++++++------- 3 files changed, 883 insertions(+), 526 deletions(-) create mode 100644 go/plugins/openai/openai_live_test.go diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 986987f12a..1d608d0983 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package openai contains the Genkit Plugin implementation for OpenAI provider package openai import ( "context" "encoding/json" - "errors" "fmt" "log/slog" "os" @@ -34,6 +34,7 @@ import ( "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/responses" "github.com/openai/openai-go/v3/shared" ) @@ -48,12 +49,6 @@ var defaultOpenAIOpts = ai.ModelOptions{ Stage: ai.ModelStageUnstable, } -var defaultImagenOpts = ai.ModelOptions{ - Supports: &internal.Media, - Versions: []string{}, - Stage: ai.ModelStageUnstable, -} - var defaultEmbedOpts = ai.EmbedderOptions{} type OpenAI struct { @@ -274,7 +269,7 @@ func newEmbedder(client *openai.Client, name string, embedOpts *ai.EmbedderOptio // newModel creates a new model without registering it in the registry func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Model { var config any - config = &openai.ChatCompletionNewParams{} + config = &responses.ResponseNewParams{} meta := &ai.ModelOptions{ Label: opts.Label, Supports: opts.Supports, @@ -290,7 +285,7 @@ func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Mode ) (*ai.ModelResponse, error) { switch config.(type) { // TODO: add support for imagen and video - case *openai.ChatCompletionNewParams: + case *responses.ResponseNewParams: return generate(ctx, client, name, input, cb) default: return generate(ctx, client, name, input, cb) @@ -341,7 +336,7 @@ func configToMap(config any) map[string]any { // generate is the entry point function to request content generation to the OpenAI client func generate(ctx context.Context, client *openai.Client, model string, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error, ) (*ai.ModelResponse, error) { - req, err := toOpenAIRequest(model, input) + req, err := toOpenAIResponseParams(model, input) if err != nil { return nil, err } @@ -363,352 +358,276 @@ func generate(ctx context.Context, client *openai.Client, model string, input *a return resp, nil } -// toOpenAIRequest translates an [ai.ModelRequest] into [openai.ChatCompletionNewParams] -func toOpenAIRequest(model string, input *ai.ModelRequest) (*openai.ChatCompletionNewParams, error) { - request, err := configFromRequest(input.Config) +// toOpenAIResponseParams translates an [ai.ModelRequest] into [responses.ResponseNewParams] +func toOpenAIResponseParams(model string, input *ai.ModelRequest) (*responses.ResponseNewParams, error) { + params, err := configFromRequest(input.Config) if err != nil { return nil, err } - if request == nil { - request = &openai.ChatCompletionNewParams{} + if params == nil { + params = &responses.ResponseNewParams{} } - request.Model = model - request.N = openai.Int(1) - - if !param.IsOmitted(request.ResponseFormat) { - return nil, errors.New("response format must be set using Genkit feature: ai.WithOutputType() or ai.WithOutputSchema()") - } - if request.ParallelToolCalls == openai.Bool(true) { - return nil, errors.New("only one tool call per turn is allowed") - } + params.Model = shared.ResponsesModel(model) + // Handle tools if len(input.Tools) > 0 { - tools, tc, err := toOpenAITools(input.Tools, input.ToolChoice) + tools, err := toOpenAITools(input.Tools) if err != nil { return nil, err } - request.Tools = tools - request.ToolChoice = *tc - } - - oaiMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(input.Messages)) - for _, m := range input.Messages { - switch m.Role { - case ai.RoleSystem: - oaiMessages = append(oaiMessages, toOpenAISystemMessage(m)) - - case ai.RoleModel: - msg, err := toOpenAIModelMessage(m) - if err != nil { - return nil, err + params.Tools = tools + switch input.ToolChoice { + case ai.ToolChoiceAuto, "": + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("auto")), } - oaiMessages = append(oaiMessages, msg) - - case ai.RoleUser: - msg, err := toOpenAIUserMessage(m) - if err != nil { - return nil, err + case ai.ToolChoiceRequired: + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("required")), } - oaiMessages = append(oaiMessages, msg) - - case ai.RoleTool: - msgs, err := toOpenAIToolMessages(m) - if err != nil { - return nil, err + case ai.ToolChoiceNone: + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("none")), } - oaiMessages = append(oaiMessages, msgs...) - default: - return nil, fmt.Errorf("unsupported role detected: %q", m.Role) + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfFunctionTool: &responses.ToolChoiceFunctionParam{ + Name: string(input.ToolChoice), + }, + } } } - request.Messages = oaiMessages - - return request, nil -} - -// toOpenAISystemMessage translates a system message contained in [ai.Message] into -// [openai.ChatCompletionMessageParamUnion] -func toOpenAISystemMessage(m *ai.Message) openai.ChatCompletionMessageParamUnion { - return openai.SystemMessage(m.Text()) -} - -// toOpenAIModelMessage translates a model message contained in [ai.Message] into -// [openai.ChatCompletionMessageParamUnion] -func toOpenAIModelMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion, error) { - var ( - textParts []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion - toolCalls []openai.ChatCompletionMessageToolCallUnionParam - ) + // messages to input items + var inputItems []responses.ResponseInputItemUnionParam + var instructions []string - for _, p := range m.Content { - if p.IsText() { - textParts = append(textParts, openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ - OfText: openai.TextContentPart(p.Text).OfText, - }) - } else if p.IsToolRequest() { - toolCall, err := convertToolCall(p) - if err != nil { - return openai.ChatCompletionMessageParamUnion{}, err - } - toolCalls = append(toolCalls, *toolCall) - } else { - slog.Warn("unsupported part for assistant message", "kind", p.Kind) + for _, m := range input.Messages { + if m.Role == ai.RoleSystem { + instructions = append(instructions, m.Text()) + continue } - } - msg := openai.ChatCompletionAssistantMessageParam{} - if len(textParts) > 0 { - msg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ - OfArrayOfContentParts: textParts, + items, err := toOpenAIInputItems(m) + if err != nil { + return nil, err } + inputItems = append(inputItems, items...) } - if len(toolCalls) > 0 { - msg.ToolCalls = toolCalls - } - return openai.ChatCompletionMessageParamUnion{ - OfAssistant: &msg, - }, nil -} -// toOpenAIUserMessage translates a user message contained in [ai.Message] into [openai.ChatCompletionMessageParamUnion] -func toOpenAIUserMessage(m *ai.Message) (openai.ChatCompletionMessageParamUnion, error) { - msg, err := toOpenAIParts(m.Content) - if err != nil { - return openai.ChatCompletionMessageParamUnion{}, err + if len(instructions) > 0 { + params.Instructions = param.NewOpt(strings.Join(instructions, "\n")) } - userParts := make([]openai.ChatCompletionContentPartUnionParam, len(msg)) - for i, p := range msg { - userParts[i] = *p + if len(inputItems) > 0 { + params.Input = responses.ResponseNewParamsInputUnion{ + OfInputItemList: inputItems, + } } - return openai.ChatCompletionMessageParamUnion{ - OfUser: &openai.ChatCompletionUserMessageParam{ - Content: openai.ChatCompletionUserMessageParamContentUnion{ - OfArrayOfContentParts: userParts, - }, - }, - }, nil + + return params, nil } -// toOpenAIToolMessages translates an [ai.Message] into a slice of [openai.ChatCompletionMessageParamUnion] -func toOpenAIToolMessages(m *ai.Message) ([]openai.ChatCompletionMessageParamUnion, error) { - var msgs []openai.ChatCompletionMessageParamUnion - for _, p := range m.Content { - if p.IsToolResponse() { - content, err := json.Marshal(p.ToolResponse.Output) - if err != nil { - return nil, fmt.Errorf("failed to marshal tool response output: %w", err) +// toOpenAIInputItems converts a Genkit message to OpenAI Input Items +func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, error) { + var items []responses.ResponseInputItemUnionParam + var partsBuffer []*ai.Part + + // flush() converts a sequence of text and media parts into a single OpenAI Input Item. + // Message roles taken in consideration: + // Model (or Assistant): converted to [responses.ResponseOutputMessageContentUnionParam] + // User/System: converted to [responses.ResponseInputContentUnionParam] + // + // This is needed for the Responses API since it forbids to use Input Items for assistant role messages + flush := func() error { + if len(partsBuffer) == 0 { + return nil + } + + if m.Role == ai.RoleModel { + // conversation-history text messages that the model previously generated + var content []responses.ResponseOutputMessageContentUnionParam + for _, p := range partsBuffer { + if p.IsText() { + content = append(content, responses.ResponseOutputMessageContentUnionParam{ + OfOutputText: &responses.ResponseOutputTextParam{ + Text: p.Text, + Annotations: []responses.ResponseOutputTextAnnotationUnionParam{}, + }, + }) + } + } + if len(content) > 0 { + // we need a unique ID for the output message + id := fmt.Sprintf("msg_%p", m) + items = append(items, responses.ResponseInputItemParamOfOutputMessage( + content, + id, + responses.ResponseOutputMessageStatusCompleted, + )) + } + } else { + var content []responses.ResponseInputContentUnionParam + for _, p := range partsBuffer { + if p.IsText() { + content = append(content, responses.ResponseInputContentParamOfInputText(p.Text)) + } else if p.IsImage() || p.IsMedia() { + content = append(content, responses.ResponseInputContentUnionParam{ + OfInputImage: &responses.ResponseInputImageParam{ + ImageURL: param.NewOpt(p.Text), + }, + }) + } + } + if len(content) > 0 { + role := responses.EasyInputMessageRoleUser + // prevent unexpected system messages being sent as User, use Developer role to + // provide new "system" instructions during the conversation + if m.Role == ai.RoleSystem { + role = responses.EasyInputMessageRole("developer") + } + items = append(items, responses.ResponseInputItemParamOfMessage( + responses.ResponseInputMessageContentListParam(content), role), + ) } - msgs = append(msgs, openai.ChatCompletionMessageParamUnion{ - OfTool: &openai.ChatCompletionToolMessageParam{ - ToolCallID: p.ToolResponse.Ref, - Content: openai.ChatCompletionToolMessageParamContentUnion{ - OfString: param.NewOpt(string(content)), - }, - }, - }) } + + partsBuffer = nil + return nil } - return msgs, nil -} -// toOpenAIParts converts a slice of [ai.Part] to a slice of [openai.ChatCompletionMessageParamUnion] -func toOpenAIParts(parts []*ai.Part) ([]*openai.ChatCompletionContentPartUnionParam, error) { - resp := make([]*openai.ChatCompletionContentPartUnionParam, 0, len(parts)) - for _, p := range parts { - part, err := toOpenAIPart(p) - if err != nil { - return nil, err + for _, p := range m.Content { + if p.IsText() || p.IsImage() || p.IsMedia() { + partsBuffer = append(partsBuffer, p) + } else if p.IsToolRequest() { + if err := flush(); err != nil { + return nil, err + } + args, err := anyToJSONString(p.ToolRequest.Input) + if err != nil { + return nil, err + } + ref := p.ToolRequest.Ref + if ref == "" { + ref = p.ToolRequest.Name + } + items = append(items, responses.ResponseInputItemParamOfFunctionCall(args, ref, p.ToolRequest.Name)) + } else if p.IsToolResponse() { + if err := flush(); err != nil { + return nil, err + } + output, err := anyToJSONString(p.ToolResponse.Output) + if err != nil { + return nil, err + } + ref := p.ToolResponse.Ref + items = append(items, responses.ResponseInputItemParamOfFunctionCallOutput(ref, output)) } - resp = append(resp, part) } - return resp, nil -} - -// toOpenAIPart translates an [ai.Part] into [openai.ChatCompletionContentPartUnionParam] -func toOpenAIPart(p *ai.Part) (*openai.ChatCompletionContentPartUnionParam, error) { - var m openai.ChatCompletionContentPartUnionParam - if p == nil { - return nil, fmt.Errorf("empty part detected") - } - - switch { - case p.IsText(): - m = openai.TextContentPart(p.Text) - case p.IsImage(), p.IsMedia(): - m = openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{ - URL: p.Text, - }) - case p.IsData(): - m = openai.FileContentPart(openai.ChatCompletionContentPartFileFileParam{ - FileData: openai.String(p.Text), - }) - default: - return nil, fmt.Errorf("unsupported part kind: %v", p.Kind) + if err := flush(); err != nil { + return nil, err } - return &m, nil + return items, nil } -// toOpenAITools converts a slice of [ai.ToolDefinition] and [ai.ToolChoice] to their appropriate openAI types -func toOpenAITools(tools []*ai.ToolDefinition, toolChoice ai.ToolChoice) ([]openai.ChatCompletionToolUnionParam, *openai.ChatCompletionToolChoiceOptionUnionParam, error) { - if tools == nil { - return nil, nil, nil - } - - toolParams := make([]openai.ChatCompletionToolUnionParam, 0, len(tools)) +// toOpenAITools converts a slice of [ai.ToolDefinition] to [responses.ToolUnionParam] +func toOpenAITools(tools []*ai.ToolDefinition) ([]responses.ToolUnionParam, error) { + var result []responses.ToolUnionParam for _, t := range tools { if t == nil || t.Name == "" { continue } - toolParams = append(toolParams, openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{ - Name: t.Name, - Description: openai.String(t.Description), - Parameters: shared.FunctionParameters(t.InputSchema), - Strict: openai.Bool(false), // TODO: implement constrained gen - })) - } - - var choice openai.ChatCompletionToolChoiceOptionUnionParam - switch toolChoice { - case ai.ToolChoiceAuto, "": - choice = openai.ChatCompletionToolChoiceOptionUnionParam{ - OfAuto: param.NewOpt(string(openai.ChatCompletionToolChoiceOptionAutoAuto)), - } - case ai.ToolChoiceRequired: - choice = openai.ChatCompletionToolChoiceOptionUnionParam{ - OfAuto: param.NewOpt(string(openai.ChatCompletionToolChoiceOptionAutoRequired)), - } - case ai.ToolChoiceNone: - choice = openai.ChatCompletionToolChoiceOptionUnionParam{ - OfAuto: param.NewOpt(string(openai.ChatCompletionToolChoiceOptionAutoNone)), - } - default: - choice = openai.ToolChoiceOptionFunctionToolChoice(openai.ChatCompletionNamedToolChoiceFunctionParam{ - Name: string(toolChoice), - }) + result = append(result, responses.ToolParamOfFunction(t.Name, t.InputSchema, false)) } - - return toolParams, &choice, nil + return result, nil } -// generateStream starts a new chat streaming completion in the OpenAI client -func generateStream(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { - if req == nil { - return nil, fmt.Errorf("empty request detected") - } - - // include usage stats by default, otherwise token count will always be zero - req.StreamOptions.IncludeUsage = openai.Bool(true) - - stream := client.Chat.Completions.NewStreaming(ctx, *req) +// generateStream starts a new streaming response +func generateStream(ctx context.Context, client *openai.Client, req *responses.ResponseNewParams, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + stream := client.Responses.NewStreaming(ctx, *req) defer stream.Close() - acc := &openai.ChatCompletionAccumulator{} + var ( + toolRefMap = make(map[string]string) + finalResp *responses.Response + ) for stream.Next() { - chunk := stream.Current() - // last chunk won't have choices but usage stats - acc.AddChunk(chunk) - if len(chunk.Choices) == 0 { - continue - } + evt := stream.Current() + chunk := &ai.ModelResponseChunk{} + + switch v := evt.AsAny().(type) { + case responses.ResponseTextDeltaEvent: + chunk.Content = append(chunk.Content, ai.NewTextPart(v.Delta)) + + case responses.ResponseFunctionCallArgumentsDeltaEvent: + name := toolRefMap[v.ItemID] + chunk.Content = append(chunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: v.ItemID, + Name: name, + Input: v.Delta, + })) + + case responses.ResponseOutputItemAddedEvent: + switch item := v.Item.AsAny().(type) { + case responses.ResponseFunctionToolCall: + toolRefMap[item.CallID] = item.Name + chunk.Content = append(chunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: item.CallID, + Name: item.Name, + })) + } - callbackChunk := &ai.ModelResponseChunk{} - chunkContent := chunk.Choices[0].Delta.Content - if chunkContent != "" { - callbackChunk.Content = append(callbackChunk.Content, ai.NewTextPart(chunkContent)) + case responses.ResponseCompletedEvent: + finalResp = &v.Response } - for _, toolCall := range chunk.Choices[0].Delta.ToolCalls { - if toolCall.Function.Name != "" || toolCall.Function.Arguments != "" { - callbackChunk.Content = append(callbackChunk.Content, - ai.NewToolRequestPart(&ai.ToolRequest{ - Name: toolCall.Function.Name, - Input: toolCall.Function.Arguments, - Ref: toolCall.ID, - })) - } - } - if len(callbackChunk.Content) > 0 { - if err := cb(ctx, callbackChunk); err != nil { + if len(chunk.Content) > 0 { + if err := cb(ctx, chunk); err != nil { return nil, fmt.Errorf("callback error: %w", err) } } } + if err := stream.Err(); err != nil { return nil, fmt.Errorf("stream error: %w", err) } - resp, err := translateResponse(&acc.ChatCompletion) - if err != nil { - return nil, err + if finalResp != nil { + mResp, err := translateResponse(finalResp) + if err != nil { + return nil, err + } + mResp.Request = input + return mResp, nil } - resp.Request = input - return resp, nil + + // prevent returning an error if stream does not provide [responses.ResponseCompletedEvent] + // user might already have received the chunks throughout the loop + return &ai.ModelResponse{ + Request: input, + Message: &ai.Message{Role: ai.RoleModel}, + }, nil } -// generateComplete starts a new chat completion in the OpenAI client -func generateComplete(ctx context.Context, client *openai.Client, req *openai.ChatCompletionNewParams, input *ai.ModelRequest) (*ai.ModelResponse, error) { - if req == nil { - return nil, fmt.Errorf("empty request detected") - } - c, err := client.Chat.Completions.New(ctx, *req) +// generateComplete starts a new completion +func generateComplete(ctx context.Context, client *openai.Client, req *responses.ResponseNewParams, input *ai.ModelRequest) (*ai.ModelResponse, error) { + resp, err := client.Responses.New(ctx, *req) if err != nil { return nil, err } - resp, err := translateResponse(c) + modelResp, err := translateResponse(resp) if err != nil { return nil, err } - - resp.Request = input - return resp, nil + modelResp.Request = input + return modelResp, nil } -// translateResponse translates an [openai.ChatCompletion] into an [ai.ModelResponse] -func translateResponse(c *openai.ChatCompletion) (*ai.ModelResponse, error) { - if len(c.Choices) == 0 { - return nil, fmt.Errorf("nothing to translate, empty response") - } - - // by default the client will always generate 1 candidate - candidate := c.Choices[0] - usage := &ai.GenerationUsage{ - InputTokens: int(c.Usage.PromptTokens), - OutputTokens: int(c.Usage.CompletionTokens), - TotalTokens: int(c.Usage.TotalTokens), - } - - rt := c.Usage.CompletionTokensDetails.ReasoningTokens - if rt > 0 { - usage.ThoughtsTokens = int(rt) - } - ct := c.Usage.PromptTokensDetails.CachedTokens - if ct > 0 { - usage.CachedContentTokens = int(ct) - } - - // custom OpenAI usage stats - if usage.Custom == nil { - usage.Custom = make(map[string]float64) - } - at := c.Usage.CompletionTokensDetails.AudioTokens - if at > 0 { - usage.Custom["audioTokens"] = float64(at) - } - apt := c.Usage.CompletionTokensDetails.AcceptedPredictionTokens - if apt > 0 { - usage.Custom["acceptedPredictionTokens"] = float64(apt) - } - rpt := c.Usage.CompletionTokensDetails.RejectedPredictionTokens - if rpt > 0 { - usage.Custom["rejectedPredictionTokens"] = float64(rpt) - } - +// translateResponse translates an [responses.Response] into an [ai.ModelResponse] +func translateResponse(r *responses.Response) (*ai.ModelResponse, error) { resp := &ai.ModelResponse{ Message: &ai.Message{ Role: ai.RoleModel, @@ -716,87 +635,68 @@ func translateResponse(c *openai.ChatCompletion) (*ai.ModelResponse, error) { }, } - switch candidate.FinishReason { - case "stop", "tool_calls": + resp.Usage = &ai.GenerationUsage{ + InputTokens: int(r.Usage.InputTokens), + OutputTokens: int(r.Usage.OutputTokens), + CachedContentTokens: int(r.Usage.InputTokensDetails.CachedTokens), + ThoughtsTokens: int(r.Usage.OutputTokensDetails.ReasoningTokens), + TotalTokens: int(r.Usage.TotalTokens), + } + + switch r.Status { + case responses.ResponseStatusCompleted: resp.FinishReason = ai.FinishReasonStop - case "length": + case responses.ResponseStatusIncomplete: resp.FinishReason = ai.FinishReasonLength - case "content_filter": - resp.FinishReason = ai.FinishReasonBlocked - case "function_call": + case responses.ResponseStatusFailed, responses.ResponseStatusCancelled: resp.FinishReason = ai.FinishReasonOther default: resp.FinishReason = ai.FinishReasonUnknown } - if candidate.Message.Refusal != "" { - resp.FinishMessage = candidate.Message.Refusal - resp.FinishReason = ai.FinishReasonBlocked - } - - // candidate custom fields - if resp.Custom == nil { - custom := map[string]any{ - "id": c.ID, - "model": c.Model, - "created": c.Created, - } - - type Citation struct { - EndIndex int64 `json:"end_index"` - StartIndex int64 `json:"start_index"` - Title string `json:"title"` - URL string `json:"url"` - } - - citations := []Citation{} - // citations for web_search tool - for _, a := range candidate.Message.Annotations { - citations = append(citations, Citation{ - EndIndex: a.URLCitation.EndIndex, - StartIndex: a.URLCitation.StartIndex, - Title: a.URLCitation.Title, - URL: a.URLCitation.URL, - }) - } - custom["citations"] = citations - resp.Custom = custom - } - - // Add tool calls - for _, toolCall := range candidate.Message.ToolCalls { - args, err := jsonStringToMap(toolCall.Function.Arguments) - if err != nil { - return nil, fmt.Errorf("could not parse tool args: %w", err) + for _, item := range r.Output { + switch v := item.AsAny().(type) { + case responses.ResponseOutputMessage: + for _, content := range v.Content { + switch c := content.AsAny().(type) { + case responses.ResponseOutputText: + resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(c.Text)) + case responses.ResponseOutputRefusal: + resp.FinishMessage = c.Refusal + resp.FinishReason = ai.FinishReasonBlocked + } + } + case responses.ResponseFunctionToolCall: + args, err := jsonStringToMap(v.Arguments) + if err != nil { + return nil, fmt.Errorf("could not parse tool args: %w", err) + } + resp.Message.Content = append(resp.Message.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: v.CallID, + Name: v.Name, + Input: args, + })) } - resp.Message.Content = append(resp.Message.Content, ai.NewToolRequestPart(&ai.ToolRequest{ - Ref: toolCall.ID, - Name: toolCall.Function.Name, - Input: args, - })) } - resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(candidate.Message.Content)) - resp.Usage = usage - return resp, nil } -// configFromRequest casts the given configuration into [openai.ChatCompletionNewParams] -func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { +// configFromRequest casts the given configuration into [responses.ResponseNewParams] +func configFromRequest(config any) (*responses.ResponseNewParams, error) { if config == nil { return nil, nil } - var openaiConfig openai.ChatCompletionNewParams + var openaiConfig responses.ResponseNewParams switch cfg := config.(type) { - case openai.ChatCompletionNewParams: + case responses.ResponseNewParams: openaiConfig = cfg - case *openai.ChatCompletionNewParams: + case *responses.ResponseNewParams: openaiConfig = *cfg case map[string]any: if err := mapToStruct(cfg, &openaiConfig); err != nil { - return nil, fmt.Errorf("failed to convert config to openai.ChatCompletionNewParams: %w", err) + return nil, fmt.Errorf("failed to convert config to responses.ResponseNewParams: %w", err) } default: return nil, fmt.Errorf("unexpected config type: %T", config) @@ -804,33 +704,6 @@ func configFromRequest(config any) (*openai.ChatCompletionNewParams, error) { return &openaiConfig, nil } -// convertToolCall translates a tool part in [ai.Part] into a [openai.ChatCompletionMessageToolCallUnionParam] -func convertToolCall(part *ai.Part) (*openai.ChatCompletionMessageToolCallUnionParam, error) { - toolCallID := part.ToolRequest.Ref - if toolCallID == "" { - toolCallID = part.ToolRequest.Name - } - - param := &openai.ChatCompletionMessageToolCallUnionParam{ - OfFunction: &openai.ChatCompletionMessageFunctionToolCallParam{ - ID: (toolCallID), - Function: (openai.ChatCompletionMessageFunctionToolCallFunctionParam{ - Name: (part.ToolRequest.Name), - }), - }, - } - - args, err := anyToJSONString(part.ToolRequest.Input) - if err != nil { - return nil, err - } - if part.ToolRequest.Input != nil { - param.OfFunction.Function.Arguments = args - } - - return param, nil -} - // anyToJSONString converts a stream of bytes to a JSON string func anyToJSONString(data any) (string, error) { jsonBytes, err := json.Marshal(data) diff --git a/go/plugins/openai/openai_live_test.go b/go/plugins/openai/openai_live_test.go new file mode 100644 index 0000000000..52e7d54bd2 --- /dev/null +++ b/go/plugins/openai/openai_live_test.go @@ -0,0 +1,395 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package openai_test + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + oai "github.com/firebase/genkit/go/plugins/openai" + openai "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared" +) + +func TestOpenAILive(t *testing.T) { + if _, ok := requireEnv("OPENAI_API_KEY"); !ok { + t.Skip("OPENAI_API_KEY not found in the environment") + } + + ctx := context.Background() + g := genkit.Init(ctx, genkit.WithPlugins(&oai.OpenAI{})) + + t.Run("model version ok", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + resp, err := genkit.Generate(ctx, g, + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithModel(m), + ai.WithSystem("talk to me like an evil pirate and say \"Arr\" several times but be very short"), + ai.WithMessages(ai.NewUserMessage(ai.NewTextPart("I'm a fish"))), + ) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(resp.Text(), "Arr") { + t.Fatalf("not a pirate:%s", resp.Text()) + } + }) + + t.Run("model version not ok", func(t *testing.T) { + m := oai.Model(g, "non-existent-model") + _, err := genkit.Generate(ctx, g, + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithModel(m), + ) + if err == nil { + t.Fatal("should have failed due wrong model version") + } + }) + + t.Run("media content", func(t *testing.T) { + i, err := fetchImgAsBase64() + if err != nil { + t.Fatal(err) + } + m := oai.Model(g, "gpt-4o") + resp, err := genkit.Generate(ctx, g, + ai.WithSystem("You are a professional image detective that talks like an evil pirate that loves animals, your task is to tell the name of the animal in the image but be very short"), + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithMessages( + ai.NewUserMessage( + ai.NewTextPart("do you know which animal is in the image?"), + ai.NewMediaPart("", "data:image/jpeg;base64,"+i)))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.ToLower(resp.Text()), "cat") { + t.Fatalf("want: cat, got: %s", resp.Text()) + } + }) + + t.Run("media content stream", func(t *testing.T) { + i, err := fetchImgAsBase64() + if err != nil { + t.Fatal(err) + } + out := "" + m := oai.Model(g, "gpt-4o") + resp, err := genkit.Generate(ctx, g, + ai.WithSystem("You are a professional image detective that talks like an evil pirate that loves animals, your task is to tell the name of the animal in the image but be very short"), + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { + out += c.Content[0].Text + return nil + }), + ai.WithMessages( + ai.NewUserMessage( + ai.NewTextPart("do you know which animal is in the image?"), + ai.NewMediaPart("", "data:image/jpeg;base64,"+i)))) + if err != nil { + t.Fatal(err) + } + if out != resp.Text() { + t.Fatalf("want: %s, got: %s", resp.Text(), out) + } + if !strings.Contains(strings.ToLower(resp.Text()), "cat") { + t.Fatalf("want: cat, got: %s", resp.Text()) + } + }) + + t.Run("media content stream with thinking", func(t *testing.T) { + i, err := fetchImgAsBase64() + if err != nil { + t.Fatal(err) + } + out := "" + m := oai.Model(g, "gpt-5") + resp, err := genkit.Generate(ctx, g, + ai.WithSystem(`You are a professional image detective that + talks like an evil pirate that loves animals, your task is to tell the name + of the animal in the image but be very short`), + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Reasoning: shared.ReasoningParam{ + Effort: shared.ReasoningEffortMedium, + }, + }), + ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { + for _, p := range c.Content { + if p.IsText() { + out += p.Text + } + } + return nil + }), + ai.WithMessages( + ai.NewUserMessage( + ai.NewTextPart("do you know which animal is in the image?"), + ai.NewMediaPart("", "data:image/jpeg;base64,"+i)))) + if err != nil { + t.Fatal(err) + } + + if out != resp.Text() { + t.Fatalf("want: %s, got: %s", resp.Text(), out) + } + if !strings.Contains(strings.ToLower(resp.Text()), "cat") { + t.Fatalf("want: cat, got: %s", resp.Text()) + } + // OpenAI does not return reasoning text, so we check usage. + if resp.Usage.ThoughtsTokens == 0 { + t.Log("No reasoning tokens found in usage (expected for reasoning models)") + } + }) + + t.Run("tools", func(t *testing.T) { + m := oai.Model(g, "gpt-5") + myJokeTool := genkit.DefineTool( + g, + "myJoke", + "When the user asks for a joke, this tool must be used to generate a joke, try to come up with a joke that uses the output of the tool", + func(ctx *ai.ToolContext, input *any) (string, error) { + return "why did the chicken cross the road?", nil + }, + ) + resp, err := genkit.Generate(ctx, g, + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithPrompt("tell me a joke"), + ai.WithTools(myJokeTool)) + if err != nil { + t.Fatal(err) + } + + if len(resp.Text()) == 0 { + t.Fatal("expected a response but nothing was returned") + } + }) + + t.Run("tools with schema", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + + type WeatherInput struct { + Location string `json:"location"` + } + + weatherTool := genkit.DefineTool( + g, + "weather", + "Returns the weather for the given location", + func(ctx *ai.ToolContext, input *WeatherInput) (string, error) { + return "sunny", nil + }, + ) + + resp, err := genkit.Generate(ctx, g, + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithPrompt("what is the weather in San Francisco?"), + ai.WithTools(weatherTool)) + if err != nil { + t.Fatal(err) + } + + if len(resp.Text()) == 0 { + t.Fatal("expected a response but nothing was returned") + } + }) + + t.Run("streaming", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + out := "" + + final, err := genkit.Generate(ctx, g, + ai.WithPrompt("Tell me a short story about a frog and a princess"), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithModel(m), + ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { + out += c.Content[0].Text + return nil + }), + ) + if err != nil { + t.Fatal(err) + } + + out2 := "" + for _, p := range final.Message.Content { + out2 += p.Text + } + + if out != out2 { + t.Fatalf("streaming and final should contain the same text.\nstreaming: %s\nfinal:%s\n", out, out2) + } + if final.Usage.InputTokens == 0 || final.Usage.OutputTokens == 0 { + t.Fatalf("empty usage stats: %#v", *final.Usage) + } + }) + + t.Run("streaming with thinking", func(t *testing.T) { + m := oai.Model(g, "gpt-5.2") + out := "" + + final, err := genkit.Generate(ctx, g, + ai.WithPrompt("Tell me a short story about a frog and a princess"), + ai.WithConfig(&responses.ResponseNewParams{ + Reasoning: shared.ReasoningParam{ + Effort: shared.ReasoningEffortHigh, + }, + }), + ai.WithModel(m), + ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { + for _, p := range c.Content { + if p.IsText() { + out += p.Text + } + } + return nil + }), + ) + if err != nil { + t.Fatal(err) + } + + out2 := "" + for _, p := range final.Message.Content { + if p.IsText() { + out2 += p.Text + } + } + if out != out2 { + t.Fatalf("streaming and final should contain the same text.\n\nstreaming: %s\n\nfinal: %s\n\n", out, out2) + } + + if final.Usage.ThoughtsTokens > 0 { + t.Logf("Reasoning tokens: %d", final.Usage.ThoughtsTokens) + } else { + // This might happen if the model decides not to reason much or if stats are missing. + t.Log("No reasoning tokens reported.") + } + }) + + t.Run("tools streaming", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + out := "" + + myStoryTool := genkit.DefineTool( + g, + "myStory", + "When the user asks for a story, create a story about a frog and a fox that are good friends", + func(ctx *ai.ToolContext, input *any) (string, error) { + return "the fox is named Goph and the frog is called Fred", nil + }, + ) + + final, err := genkit.Generate(ctx, g, + ai.WithPrompt("Tell me a short story about a frog and a fox, do no mention anything else, only the short story"), + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + }), + ai.WithTools(myStoryTool), + ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { + out += c.Content[0].Text + return nil + }), + ) + if err != nil { + t.Fatal(err) + } + + out2 := "" + for _, p := range final.Message.Content { + if p.IsText() { + out2 += p.Text + } + } + + if out != out2 { + t.Fatalf("streaming and final should contain the same text\n\nstreaming: %s\n\nfinal: %s\n\n", out, out2) + } + if final.Usage.InputTokens == 0 || final.Usage.OutputTokens == 0 { + t.Fatalf("empty usage stats: %#v", *final.Usage) + } + }) + + t.Run("tools streaming with constrained gen", func(t *testing.T) { + t.Skip("skipped until constrained gen is implemented") + // ... implementation would be here + }) +} + +func fetchImgAsBase64() (string, error) { + // CC0 license image + imgURL := "https://pd.w.org/2025/07/896686fbbcd9990c9.84605288-2048x1365.jpg" + resp, err := http.Get(imgURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", err + } + + imageBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + base64string := base64.StdEncoding.EncodeToString(imageBytes) + return base64string, nil +} + +func requireEnv(key string) (string, bool) { + value, ok := os.LookupEnv(key) + if !ok || value == "" { + return "", false + } + + return value, true +} diff --git a/go/plugins/openai/openai_test.go b/go/plugins/openai/openai_test.go index 52071c7335..e8b8dc87e8 100644 --- a/go/plugins/openai/openai_test.go +++ b/go/plugins/openai/openai_test.go @@ -16,177 +16,258 @@ package openai import ( "encoding/json" - "reflect" + "strings" "testing" "github.com/firebase/genkit/go/ai" - "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared" ) -func TestToOpenAISystemMessage(t *testing.T) { +func TestToOpenAIResponseParams_SystemMessage(t *testing.T) { + msg := &ai.Message{ + Role: ai.RoleSystem, + Content: []*ai.Part{ai.NewTextPart("system instruction")}, + } + req := &ai.ModelRequest{ + Messages: []*ai.Message{msg}, + } + + params, err := toOpenAIResponseParams("gpt-4o", req) + if err != nil { + t.Fatalf("toOpenAIResponseParams() error = %v", err) + } + + if params.Instructions.Value != "system instruction" { + t.Errorf("Instructions = %q, want %q", params.Instructions.Value, "system instruction") + } +} + +func TestToOpenAIInputItems_JSON(t *testing.T) { tests := []struct { name string msg *ai.Message - want string + want []string // substrings to match in JSON }{ { - name: "basic system message", + name: "user text message", + msg: &ai.Message{ + Role: ai.RoleUser, + Content: []*ai.Part{ai.NewTextPart("user query")}, + }, + want: []string{`"role":"user"`, `"type":"input_text"`, `"text":"user query"`}, + }, + { + name: "model text message", + msg: &ai.Message{ + Role: ai.RoleModel, + Content: []*ai.Part{ai.NewTextPart("model response")}, + }, + want: []string{`"role":"assistant"`, `"type":"output_text"`, `"text":"model response"`, `"status":"completed"`}, + }, + { + name: "tool request", + msg: &ai.Message{ + Role: ai.RoleModel, + Content: []*ai.Part{ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "myTool", + Ref: "call_123", + Input: map[string]string{"arg": "val"}, + })}, + }, + want: []string{`"type":"function_call"`, `"name":"myTool"`, `"call_id":"call_123"`, `"arguments":"{\"arg\":\"val\"}"`}, + }, + { + name: "tool response", msg: &ai.Message{ - Role: ai.RoleSystem, - Content: []*ai.Part{ai.NewTextPart("system instruction")}, + Role: ai.RoleTool, + Content: []*ai.Part{ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "myTool", + Ref: "call_123", + Output: map[string]string{"res": "ok"}, + })}, }, - want: "system instruction", + want: []string{`"type":"function_call_output"`, `"call_id":"call_123"`, `"output":"{\"res\":\"ok\"}"`}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - gotUnion := toOpenAISystemMessage(tc.msg) - - if gotUnion.OfSystem == nil { - t.Fatalf("toOpenAISystemMessage() returned union with nil OfSystem") + items, err := toOpenAIInputItems(tc.msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - val := gotUnion.OfSystem.Content.OfString.Value - if val != tc.want { - t.Errorf("toOpenAISystemMessage() content = %q, want %q", val, tc.want) + b, err := json.Marshal(items) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + jsonStr := string(b) + + for _, w := range tc.want { + if !strings.Contains(jsonStr, w) { + t.Errorf("JSON output missing %q. Got: %s", w, jsonStr) + } } }) } } -func TestToOpenAIModelMessage(t *testing.T) { +func TestToOpenAITools(t *testing.T) { tests := []struct { name string - msg *ai.Message - checkFunc func(*testing.T, openai.ChatCompletionMessageParamUnion) + tools []*ai.ToolDefinition + wantCount int + check func(*testing.T, []responses.ToolUnionParam) }{ { - name: "text message", - msg: &ai.Message{ - Role: ai.RoleModel, - Content: []*ai.Part{ai.NewTextPart("model response")}, + name: "basic function tool", + tools: []*ai.ToolDefinition{ + { + Name: "myTool", + Description: "does something", + InputSchema: map[string]any{"type": "object"}, + }, }, - checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { - if gotUnion.OfAssistant == nil { - t.Fatalf("expected OfAssistant to be non-nil") - } - parts := gotUnion.OfAssistant.Content.OfArrayOfContentParts - if len(parts) != 1 { - t.Fatalf("got %d content parts, want 1", len(parts)) + wantCount: 1, + check: func(t *testing.T, got []responses.ToolUnionParam) { + tool := got[0] + // We need to marshal to check fields since they are hidden in UnionParam + b, _ := json.Marshal(tool) + s := string(b) + if !strings.Contains(s, `"name":"myTool"`) { + t.Errorf("missing name: %s", s) } - textPart := parts[0].OfText - if got, want := textPart.Text, "model response"; got != want { - t.Errorf("content = %q, want %q", got, want) + if !strings.Contains(s, `"type":"function"`) { + t.Errorf("missing type function: %s", s) } }, }, { - name: "tool call message", - msg: &ai.Message{ - Role: ai.RoleModel, - Content: []*ai.Part{ - ai.NewToolRequestPart(&ai.ToolRequest{ - Name: "myTool", - Ref: "call_123", - Input: map[string]any{"arg": "value"}, - }), - }, - }, - checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { - if gotUnion.OfAssistant == nil { - t.Fatalf("expected OfAssistant to be non-nil") - } - toolCalls := gotUnion.OfAssistant.ToolCalls - if len(toolCalls) != 1 { - t.Fatalf("got %d tool calls, want 1", len(toolCalls)) - } - - if toolCalls[0].OfFunction == nil { - t.Fatalf("expected Function tool call") - } - fnCall := toolCalls[0].OfFunction - - if got, want := fnCall.ID, "call_123"; got != want { - t.Errorf("tool call ID = %q, want %q", got, want) - } - if got, want := fnCall.Function.Name, "myTool"; got != want { - t.Errorf("function name = %q, want %q", got, want) - } - - var gotArgs map[string]any - if err := json.Unmarshal([]byte(fnCall.Function.Arguments), &gotArgs); err != nil { - t.Fatalf("failed to unmarshal arguments: %v", err) - } - wantArgs := map[string]any{"arg": "value"} - if !reflect.DeepEqual(gotArgs, wantArgs) { - t.Errorf("arguments = %v, want %v", gotArgs, wantArgs) - } + name: "empty name tool ignored", + tools: []*ai.ToolDefinition{ + {Name: ""}, }, + wantCount: 0, + check: func(t *testing.T, got []responses.ToolUnionParam) {}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := toOpenAIModelMessage(tc.msg) + got, err := toOpenAITools(tc.tools) if err != nil { - t.Fatalf("toOpenAIModelMessage() unexpected error: %v", err) + t.Fatalf("unexpected error: %v", err) } - tc.checkFunc(t, got) + if len(got) != tc.wantCount { + t.Errorf("got %d tools, want %d", len(got), tc.wantCount) + } + tc.check(t, got) }) } } -func TestToOpenAIUserMessage(t *testing.T) { +func TestTranslateResponse(t *testing.T) { + // this is a workaround function to bypass Union types used in the openai-go SDK + createResponse := func(jsonStr string) *responses.Response { + var r responses.Response + if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { + t.Fatalf("failed to create mock response: %v", err) + } + return &r + } + tests := []struct { - name string - msg *ai.Message - checkFunc func(*testing.T, openai.ChatCompletionMessageParamUnion) + name string + respJSON string + wantReason ai.FinishReason + check func(*testing.T, *ai.ModelResponse) }{ { - name: "text message", - msg: &ai.Message{ - Role: ai.RoleUser, - Content: []*ai.Part{ai.NewTextPart("user query")}, - }, - checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { - if gotUnion.OfUser == nil { - t.Fatalf("expected OfUser to be non-nil") - } - parts := gotUnion.OfUser.Content.OfArrayOfContentParts - if len(parts) != 1 { - t.Fatalf("got %d content parts, want 1", len(parts)) + name: "text response completed", + respJSON: `{ + "id": "resp_1", + "status": "completed", + "usage": {"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}, + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello world"}] + } + ] + }`, + wantReason: ai.FinishReasonStop, + check: func(t *testing.T, m *ai.ModelResponse) { + if len(m.Message.Content) != 1 { + t.Fatalf("got %d parts, want 1", len(m.Message.Content)) } - textPart := parts[0].OfText - if textPart == nil { - t.Fatalf("expected Text content part") + if got := m.Message.Content[0].Text; got != "Hello world" { + t.Errorf("got text %q, want 'Hello world'", got) } - if got, want := textPart.Text, "user query"; got != want { - t.Errorf("content = %q, want %q", got, want) + if m.Usage.TotalTokens != 30 { + t.Errorf("got usage %d, want 30", m.Usage.TotalTokens) } }, }, { - name: "image message", - msg: &ai.Message{ - Role: ai.RoleUser, - Content: []*ai.Part{ - ai.NewMediaPart("image/png", "http://example.com/image.png"), - }, + name: "incomplete response", + respJSON: `{ + "id": "resp_2", + "status": "incomplete", + "output": [] + }`, + wantReason: ai.FinishReasonLength, // mapped from Incomplete + check: func(t *testing.T, m *ai.ModelResponse) {}, + }, + { + name: "refusal response", + respJSON: `{ + "id": "resp_3", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "I cannot do that"}] + } + ] + }`, + wantReason: ai.FinishReasonBlocked, + check: func(t *testing.T, m *ai.ModelResponse) { + if m.FinishMessage != "I cannot do that" { + t.Errorf("got FinishMessage %q, want 'I cannot do that'", m.FinishMessage) + } }, - checkFunc: func(t *testing.T, gotUnion openai.ChatCompletionMessageParamUnion) { - if gotUnion.OfUser == nil { - t.Fatalf("expected OfUser to be non-nil") + }, + { + name: "tool call", + respJSON: `{ + "id": "resp_4", + "status": "completed", + "output": [ + { + "type": "function_call", + "call_id": "call_abc", + "name": "weather", + "arguments": "{\"loc\":\"SFO\"}" + } + ] + }`, + wantReason: ai.FinishReasonStop, + check: func(t *testing.T, m *ai.ModelResponse) { + if len(m.Message.Content) != 1 { + t.Fatalf("got %d parts, want 1", len(m.Message.Content)) } - parts := gotUnion.OfUser.Content.OfArrayOfContentParts - if len(parts) != 1 { - t.Fatalf("got %d content parts, want 1", len(parts)) + p := m.Message.Content[0] + if !p.IsToolRequest() { + t.Fatalf("expected tool request part") } - imagePart := parts[0].OfImageURL - if imagePart == nil { - t.Fatalf("expected Image content part") + if p.ToolRequest.Name != "weather" { + t.Errorf("got tool name %q, want 'weather'", p.ToolRequest.Name) } - if got, want := imagePart.ImageURL.URL, "http://example.com/image.png"; got != want { - t.Errorf("image URL = %q, want %q", got, want) + args := p.ToolRequest.Input.(map[string]any) + if args["loc"] != "SFO" { + t.Errorf("got arg loc %v, want 'SFO'", args["loc"]) } }, }, @@ -194,67 +275,75 @@ func TestToOpenAIUserMessage(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := toOpenAIUserMessage(tc.msg) + r := createResponse(tc.respJSON) + got, err := translateResponse(r) if err != nil { - t.Fatalf("toOpenAIUserMessage() unexpected error: %v", err) + t.Fatalf("translateResponse() unexpected error: %v", err) + } + if got.FinishReason != tc.wantReason { + t.Errorf("got reason %v, want %v", got.FinishReason, tc.wantReason) } - tc.checkFunc(t, got) + tc.check(t, got) }) } } -func TestToOpenAIToolMessages(t *testing.T) { +func TestConfigFromRequest(t *testing.T) { tests := []struct { - name string - msg *ai.Message - checkFunc func(*testing.T, []openai.ChatCompletionMessageParamUnion) + name string + input any + wantErr bool + check func(*testing.T, *responses.ResponseNewParams) }{ { - name: "tool response", - msg: &ai.Message{ - Role: ai.RoleTool, - Content: []*ai.Part{ - ai.NewToolResponsePart(&ai.ToolResponse{ - Name: "myTool", - Ref: "call_123", - Output: map[string]any{"result": "success"}, - }), - }, - }, - checkFunc: func(t *testing.T, gotMsgs []openai.ChatCompletionMessageParamUnion) { - if len(gotMsgs) != 1 { - t.Fatalf("got %d messages, want 1", len(gotMsgs)) + name: "nil config", + input: nil, + check: func(t *testing.T, got *responses.ResponseNewParams) { + if got != nil { + t.Errorf("expected nil params") } - if gotMsgs[0].OfTool == nil { - t.Fatalf("expected OfTool to be non-nil") - } - toolMsg := gotMsgs[0].OfTool - if got, want := toolMsg.ToolCallID, "call_123"; got != want { - t.Errorf("tool call ID = %q, want %q", got, want) - } - - // Content is Union. Expecting OfString. - content := toolMsg.Content.OfString.Value - - var gotOutput map[string]any - if err := json.Unmarshal([]byte(content), &gotOutput); err != nil { - t.Fatalf("failed to unmarshal output: %v", err) + }, + }, + { + name: "struct config", + input: responses.ResponseNewParams{ + Model: shared.ResponsesModel("gpt-4o"), + }, + check: func(t *testing.T, got *responses.ResponseNewParams) { + if got.Model != shared.ResponsesModel("gpt-4o") { + t.Errorf("got model %v, want %v", got.Model, shared.ResponsesModel("gpt-4o")) } - wantOutput := map[string]any{"result": "success"} - if !reflect.DeepEqual(gotOutput, wantOutput) { - t.Errorf("output = %v, want %v", gotOutput, wantOutput) + }, + }, + { + name: "map config", + input: map[string]any{ + "model": "gpt-4o", + }, + check: func(t *testing.T, got *responses.ResponseNewParams) { + if got.Model != "gpt-4o" { + t.Errorf("got model %v, want gpt-4o", got.Model) } }, }, + { + name: "invalid type", + input: "some string", + wantErr: true, + check: func(t *testing.T, got *responses.ResponseNewParams) {}, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := toOpenAIToolMessages(tc.msg) - if err != nil { - t.Fatalf("toOpenAIToolMessages() unexpected error: %v", err) + got, err := configFromRequest(tc.input) + if (err != nil) != tc.wantErr { + t.Errorf("configFromRequest() error = %v, wantErr %v", err, tc.wantErr) + return + } + if !tc.wantErr { + tc.check(t, got) } - tc.checkFunc(t, got) }) } } From 145bce5f097c5f1a110ae43b04f4868f54cf2092 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Mon, 12 Jan 2026 22:14:23 +0000 Subject: [PATCH 10/20] update file structure --- go/plugins/openai/generate.go | 132 ++++++++++ go/plugins/openai/openai.go | 442 -------------------------------- go/plugins/openai/translator.go | 358 ++++++++++++++++++++++++++ 3 files changed, 490 insertions(+), 442 deletions(-) create mode 100644 go/plugins/openai/generate.go create mode 100644 go/plugins/openai/translator.go diff --git a/go/plugins/openai/generate.go b/go/plugins/openai/generate.go new file mode 100644 index 0000000000..ffafe50130 --- /dev/null +++ b/go/plugins/openai/generate.go @@ -0,0 +1,132 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package openai + +import ( + "context" + "fmt" + + "github.com/firebase/genkit/go/ai" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" +) + +// generate is the entry point function to request content generation to the OpenAI client +func generate(ctx context.Context, client *openai.Client, model string, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error, +) (*ai.ModelResponse, error) { + req, err := toOpenAIResponseParams(model, input) + if err != nil { + return nil, err + } + + // stream mode + if cb != nil { + resp, err := generateStream(ctx, client, req, input, cb) + if err != nil { + return nil, err + } + return resp, nil + + } + + resp, err := generateComplete(ctx, client, req, input) + if err != nil { + return nil, err + } + return resp, nil +} + +// generateStream starts a new streaming response +func generateStream(ctx context.Context, client *openai.Client, req *responses.ResponseNewParams, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + stream := client.Responses.NewStreaming(ctx, *req) + defer stream.Close() + + var ( + toolRefMap = make(map[string]string) + finalResp *responses.Response + ) + + for stream.Next() { + evt := stream.Current() + chunk := &ai.ModelResponseChunk{} + + switch v := evt.AsAny().(type) { + case responses.ResponseTextDeltaEvent: + chunk.Content = append(chunk.Content, ai.NewTextPart(v.Delta)) + + case responses.ResponseFunctionCallArgumentsDeltaEvent: + name := toolRefMap[v.ItemID] + chunk.Content = append(chunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: v.ItemID, + Name: name, + Input: v.Delta, + })) + + case responses.ResponseOutputItemAddedEvent: + switch item := v.Item.AsAny().(type) { + case responses.ResponseFunctionToolCall: + toolRefMap[item.CallID] = item.Name + chunk.Content = append(chunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: item.CallID, + Name: item.Name, + })) + } + + case responses.ResponseCompletedEvent: + finalResp = &v.Response + } + + if len(chunk.Content) > 0 { + if err := cb(ctx, chunk); err != nil { + return nil, fmt.Errorf("callback error: %w", err) + } + } + } + + if err := stream.Err(); err != nil { + return nil, fmt.Errorf("stream error: %w", err) + } + + if finalResp != nil { + mResp, err := translateResponse(finalResp) + if err != nil { + return nil, err + } + mResp.Request = input + return mResp, nil + } + + // prevent returning an error if stream does not provide [responses.ResponseCompletedEvent] + // user might already have received the chunks throughout the loop + return &ai.ModelResponse{ + Request: input, + Message: &ai.Message{Role: ai.RoleModel}, + }, nil +} + +// generateComplete starts a new completion +func generateComplete(ctx context.Context, client *openai.Client, req *responses.ResponseNewParams, input *ai.ModelRequest) (*ai.ModelResponse, error) { + resp, err := client.Responses.New(ctx, *req) + if err != nil { + return nil, err + } + + modelResp, err := translateResponse(resp) + if err != nil { + return nil, err + } + modelResp.Request = input + return modelResp, nil +} diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 1d608d0983..d464c5c17e 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -17,25 +17,19 @@ package openai import ( "context" - "encoding/json" "fmt" "log/slog" "os" - "reflect" "strings" "sync" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/genkit" - "github.com/firebase/genkit/go/internal/base" "github.com/firebase/genkit/go/plugins/internal" - "github.com/invopop/jsonschema" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" - "github.com/openai/openai-go/v3/packages/param" "github.com/openai/openai-go/v3/responses" - "github.com/openai/openai-go/v3/shared" ) const ( @@ -294,439 +288,3 @@ func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Mode return ai.NewModel(api.NewName(openaiProvider, name), meta, fn) } - -// configToMap converts a config struct to a map[string]any -func configToMap(config any) map[string]any { - r := jsonschema.Reflector{ - DoNotReference: true, - AllowAdditionalProperties: false, - ExpandedStruct: true, - RequiredFromJSONSchemaTags: true, - } - - r.Mapper = func(r reflect.Type) *jsonschema.Schema { - if r.Name() == "Opt[float64]" { - return &jsonschema.Schema{ - Type: "number", - } - } - if r.Name() == "Opt[int64]" { - return &jsonschema.Schema{ - Type: "integer", - } - } - if r.Name() == "Opt[string]" { - return &jsonschema.Schema{ - Type: "string", - } - } - if r.Name() == "Opt[bool]" { - return &jsonschema.Schema{ - Type: "boolean", - } - } - return nil - } - schema := r.Reflect(config) - result := base.SchemaAsMap(schema) - - return result -} - -// generate is the entry point function to request content generation to the OpenAI client -func generate(ctx context.Context, client *openai.Client, model string, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error, -) (*ai.ModelResponse, error) { - req, err := toOpenAIResponseParams(model, input) - if err != nil { - return nil, err - } - - // stream mode - if cb != nil { - resp, err := generateStream(ctx, client, req, input, cb) - if err != nil { - return nil, err - } - return resp, nil - - } - - resp, err := generateComplete(ctx, client, req, input) - if err != nil { - return nil, err - } - return resp, nil -} - -// toOpenAIResponseParams translates an [ai.ModelRequest] into [responses.ResponseNewParams] -func toOpenAIResponseParams(model string, input *ai.ModelRequest) (*responses.ResponseNewParams, error) { - params, err := configFromRequest(input.Config) - if err != nil { - return nil, err - } - if params == nil { - params = &responses.ResponseNewParams{} - } - - params.Model = shared.ResponsesModel(model) - - // Handle tools - if len(input.Tools) > 0 { - tools, err := toOpenAITools(input.Tools) - if err != nil { - return nil, err - } - params.Tools = tools - switch input.ToolChoice { - case ai.ToolChoiceAuto, "": - params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ - OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("auto")), - } - case ai.ToolChoiceRequired: - params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ - OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("required")), - } - case ai.ToolChoiceNone: - params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ - OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("none")), - } - default: - params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ - OfFunctionTool: &responses.ToolChoiceFunctionParam{ - Name: string(input.ToolChoice), - }, - } - } - } - - // messages to input items - var inputItems []responses.ResponseInputItemUnionParam - var instructions []string - - for _, m := range input.Messages { - if m.Role == ai.RoleSystem { - instructions = append(instructions, m.Text()) - continue - } - - items, err := toOpenAIInputItems(m) - if err != nil { - return nil, err - } - inputItems = append(inputItems, items...) - } - - if len(instructions) > 0 { - params.Instructions = param.NewOpt(strings.Join(instructions, "\n")) - } - if len(inputItems) > 0 { - params.Input = responses.ResponseNewParamsInputUnion{ - OfInputItemList: inputItems, - } - } - - return params, nil -} - -// toOpenAIInputItems converts a Genkit message to OpenAI Input Items -func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, error) { - var items []responses.ResponseInputItemUnionParam - var partsBuffer []*ai.Part - - // flush() converts a sequence of text and media parts into a single OpenAI Input Item. - // Message roles taken in consideration: - // Model (or Assistant): converted to [responses.ResponseOutputMessageContentUnionParam] - // User/System: converted to [responses.ResponseInputContentUnionParam] - // - // This is needed for the Responses API since it forbids to use Input Items for assistant role messages - flush := func() error { - if len(partsBuffer) == 0 { - return nil - } - - if m.Role == ai.RoleModel { - // conversation-history text messages that the model previously generated - var content []responses.ResponseOutputMessageContentUnionParam - for _, p := range partsBuffer { - if p.IsText() { - content = append(content, responses.ResponseOutputMessageContentUnionParam{ - OfOutputText: &responses.ResponseOutputTextParam{ - Text: p.Text, - Annotations: []responses.ResponseOutputTextAnnotationUnionParam{}, - }, - }) - } - } - if len(content) > 0 { - // we need a unique ID for the output message - id := fmt.Sprintf("msg_%p", m) - items = append(items, responses.ResponseInputItemParamOfOutputMessage( - content, - id, - responses.ResponseOutputMessageStatusCompleted, - )) - } - } else { - var content []responses.ResponseInputContentUnionParam - for _, p := range partsBuffer { - if p.IsText() { - content = append(content, responses.ResponseInputContentParamOfInputText(p.Text)) - } else if p.IsImage() || p.IsMedia() { - content = append(content, responses.ResponseInputContentUnionParam{ - OfInputImage: &responses.ResponseInputImageParam{ - ImageURL: param.NewOpt(p.Text), - }, - }) - } - } - if len(content) > 0 { - role := responses.EasyInputMessageRoleUser - // prevent unexpected system messages being sent as User, use Developer role to - // provide new "system" instructions during the conversation - if m.Role == ai.RoleSystem { - role = responses.EasyInputMessageRole("developer") - } - items = append(items, responses.ResponseInputItemParamOfMessage( - responses.ResponseInputMessageContentListParam(content), role), - ) - } - } - - partsBuffer = nil - return nil - } - - for _, p := range m.Content { - if p.IsText() || p.IsImage() || p.IsMedia() { - partsBuffer = append(partsBuffer, p) - } else if p.IsToolRequest() { - if err := flush(); err != nil { - return nil, err - } - args, err := anyToJSONString(p.ToolRequest.Input) - if err != nil { - return nil, err - } - ref := p.ToolRequest.Ref - if ref == "" { - ref = p.ToolRequest.Name - } - items = append(items, responses.ResponseInputItemParamOfFunctionCall(args, ref, p.ToolRequest.Name)) - } else if p.IsToolResponse() { - if err := flush(); err != nil { - return nil, err - } - output, err := anyToJSONString(p.ToolResponse.Output) - if err != nil { - return nil, err - } - ref := p.ToolResponse.Ref - items = append(items, responses.ResponseInputItemParamOfFunctionCallOutput(ref, output)) - } - } - if err := flush(); err != nil { - return nil, err - } - - return items, nil -} - -// toOpenAITools converts a slice of [ai.ToolDefinition] to [responses.ToolUnionParam] -func toOpenAITools(tools []*ai.ToolDefinition) ([]responses.ToolUnionParam, error) { - var result []responses.ToolUnionParam - for _, t := range tools { - if t == nil || t.Name == "" { - continue - } - result = append(result, responses.ToolParamOfFunction(t.Name, t.InputSchema, false)) - } - return result, nil -} - -// generateStream starts a new streaming response -func generateStream(ctx context.Context, client *openai.Client, req *responses.ResponseNewParams, input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { - stream := client.Responses.NewStreaming(ctx, *req) - defer stream.Close() - - var ( - toolRefMap = make(map[string]string) - finalResp *responses.Response - ) - - for stream.Next() { - evt := stream.Current() - chunk := &ai.ModelResponseChunk{} - - switch v := evt.AsAny().(type) { - case responses.ResponseTextDeltaEvent: - chunk.Content = append(chunk.Content, ai.NewTextPart(v.Delta)) - - case responses.ResponseFunctionCallArgumentsDeltaEvent: - name := toolRefMap[v.ItemID] - chunk.Content = append(chunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ - Ref: v.ItemID, - Name: name, - Input: v.Delta, - })) - - case responses.ResponseOutputItemAddedEvent: - switch item := v.Item.AsAny().(type) { - case responses.ResponseFunctionToolCall: - toolRefMap[item.CallID] = item.Name - chunk.Content = append(chunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ - Ref: item.CallID, - Name: item.Name, - })) - } - - case responses.ResponseCompletedEvent: - finalResp = &v.Response - } - - if len(chunk.Content) > 0 { - if err := cb(ctx, chunk); err != nil { - return nil, fmt.Errorf("callback error: %w", err) - } - } - } - - if err := stream.Err(); err != nil { - return nil, fmt.Errorf("stream error: %w", err) - } - - if finalResp != nil { - mResp, err := translateResponse(finalResp) - if err != nil { - return nil, err - } - mResp.Request = input - return mResp, nil - } - - // prevent returning an error if stream does not provide [responses.ResponseCompletedEvent] - // user might already have received the chunks throughout the loop - return &ai.ModelResponse{ - Request: input, - Message: &ai.Message{Role: ai.RoleModel}, - }, nil -} - -// generateComplete starts a new completion -func generateComplete(ctx context.Context, client *openai.Client, req *responses.ResponseNewParams, input *ai.ModelRequest) (*ai.ModelResponse, error) { - resp, err := client.Responses.New(ctx, *req) - if err != nil { - return nil, err - } - - modelResp, err := translateResponse(resp) - if err != nil { - return nil, err - } - modelResp.Request = input - return modelResp, nil -} - -// translateResponse translates an [responses.Response] into an [ai.ModelResponse] -func translateResponse(r *responses.Response) (*ai.ModelResponse, error) { - resp := &ai.ModelResponse{ - Message: &ai.Message{ - Role: ai.RoleModel, - Content: make([]*ai.Part, 0), - }, - } - - resp.Usage = &ai.GenerationUsage{ - InputTokens: int(r.Usage.InputTokens), - OutputTokens: int(r.Usage.OutputTokens), - CachedContentTokens: int(r.Usage.InputTokensDetails.CachedTokens), - ThoughtsTokens: int(r.Usage.OutputTokensDetails.ReasoningTokens), - TotalTokens: int(r.Usage.TotalTokens), - } - - switch r.Status { - case responses.ResponseStatusCompleted: - resp.FinishReason = ai.FinishReasonStop - case responses.ResponseStatusIncomplete: - resp.FinishReason = ai.FinishReasonLength - case responses.ResponseStatusFailed, responses.ResponseStatusCancelled: - resp.FinishReason = ai.FinishReasonOther - default: - resp.FinishReason = ai.FinishReasonUnknown - } - - for _, item := range r.Output { - switch v := item.AsAny().(type) { - case responses.ResponseOutputMessage: - for _, content := range v.Content { - switch c := content.AsAny().(type) { - case responses.ResponseOutputText: - resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(c.Text)) - case responses.ResponseOutputRefusal: - resp.FinishMessage = c.Refusal - resp.FinishReason = ai.FinishReasonBlocked - } - } - case responses.ResponseFunctionToolCall: - args, err := jsonStringToMap(v.Arguments) - if err != nil { - return nil, fmt.Errorf("could not parse tool args: %w", err) - } - resp.Message.Content = append(resp.Message.Content, ai.NewToolRequestPart(&ai.ToolRequest{ - Ref: v.CallID, - Name: v.Name, - Input: args, - })) - } - } - - return resp, nil -} - -// configFromRequest casts the given configuration into [responses.ResponseNewParams] -func configFromRequest(config any) (*responses.ResponseNewParams, error) { - if config == nil { - return nil, nil - } - - var openaiConfig responses.ResponseNewParams - switch cfg := config.(type) { - case responses.ResponseNewParams: - openaiConfig = cfg - case *responses.ResponseNewParams: - openaiConfig = *cfg - case map[string]any: - if err := mapToStruct(cfg, &openaiConfig); err != nil { - return nil, fmt.Errorf("failed to convert config to responses.ResponseNewParams: %w", err) - } - default: - return nil, fmt.Errorf("unexpected config type: %T", config) - } - return &openaiConfig, nil -} - -// anyToJSONString converts a stream of bytes to a JSON string -func anyToJSONString(data any) (string, error) { - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", fmt.Errorf("failed to marshal any to JSON string: %w", err) - } - return string(jsonBytes), nil -} - -// mapToStruct converts the provided map into a given struct -func mapToStruct(m map[string]any, v any) error { - jsonData, err := json.Marshal(m) - if err != nil { - return err - } - return json.Unmarshal(jsonData, v) -} - -// jsonStringToMap translates a JSON string into a map -func jsonStringToMap(jsonString string) (map[string]any, error) { - var result map[string]any - if err := json.Unmarshal([]byte(jsonString), &result); err != nil { - return nil, fmt.Errorf("unmarshal failed to parse json string %s: %w", jsonString, err) - } - return result, nil -} diff --git a/go/plugins/openai/translator.go b/go/plugins/openai/translator.go new file mode 100644 index 0000000000..ff7d32d595 --- /dev/null +++ b/go/plugins/openai/translator.go @@ -0,0 +1,358 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package openai + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/internal/base" + "github.com/invopop/jsonschema" + "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared" +) + +// toOpenAIResponseParams translates an [ai.ModelRequest] into [responses.ResponseNewParams] +func toOpenAIResponseParams(model string, input *ai.ModelRequest) (*responses.ResponseNewParams, error) { + params, err := configFromRequest(input.Config) + if err != nil { + return nil, err + } + if params == nil { + params = &responses.ResponseNewParams{} + } + + params.Model = shared.ResponsesModel(model) + + // Handle tools + if len(input.Tools) > 0 { + + tools, err := toOpenAITools(input.Tools) + if err != nil { + return nil, err + } + params.Tools = tools + switch input.ToolChoice { + case ai.ToolChoiceAuto, "": + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("auto")), + } + case ai.ToolChoiceRequired: + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("required")), + } + case ai.ToolChoiceNone: + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptions("none")), + } + default: + params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfFunctionTool: &responses.ToolChoiceFunctionParam{ + Name: string(input.ToolChoice), + }, + } + } + } + + // messages to input items + var inputItems []responses.ResponseInputItemUnionParam + var instructions []string + + for _, m := range input.Messages { + if m.Role == ai.RoleSystem { + instructions = append(instructions, m.Text()) + continue + } + + items, err := toOpenAIInputItems(m) + if err != nil { + return nil, err + } + inputItems = append(inputItems, items...) + } + + if len(instructions) > 0 { + params.Instructions = param.NewOpt(strings.Join(instructions, "\n")) + } + if len(inputItems) > 0 { + params.Input = responses.ResponseNewParamsInputUnion{ + OfInputItemList: inputItems, + } + } + + return params, nil +} + +// translateResponse translates an [responses.Response] into an [ai.ModelResponse] +func translateResponse(r *responses.Response) (*ai.ModelResponse, error) { + resp := &ai.ModelResponse{ + Message: &ai.Message{ + Role: ai.RoleModel, + Content: make([]*ai.Part, 0), + }, + } + + resp.Usage = &ai.GenerationUsage{ + InputTokens: int(r.Usage.InputTokens), + OutputTokens: int(r.Usage.OutputTokens), + CachedContentTokens: int(r.Usage.InputTokensDetails.CachedTokens), + ThoughtsTokens: int(r.Usage.OutputTokensDetails.ReasoningTokens), + TotalTokens: int(r.Usage.TotalTokens), + } + + switch r.Status { + case responses.ResponseStatusCompleted: + resp.FinishReason = ai.FinishReasonStop + case responses.ResponseStatusIncomplete: + resp.FinishReason = ai.FinishReasonLength + case responses.ResponseStatusFailed, responses.ResponseStatusCancelled: + resp.FinishReason = ai.FinishReasonOther + default: + resp.FinishReason = ai.FinishReasonUnknown + } + + for _, item := range r.Output { + switch v := item.AsAny().(type) { + case responses.ResponseOutputMessage: + for _, content := range v.Content { + switch c := content.AsAny().(type) { + case responses.ResponseOutputText: + resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(c.Text)) + case responses.ResponseOutputRefusal: + resp.FinishMessage = c.Refusal + resp.FinishReason = ai.FinishReasonBlocked + } + } + case responses.ResponseFunctionToolCall: + args, err := jsonStringToMap(v.Arguments) + if err != nil { + return nil, fmt.Errorf("could not parse tool args: %w", err) + } + resp.Message.Content = append(resp.Message.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: v.CallID, + Name: v.Name, + Input: args, + })) + } + } + + return resp, nil +} + +// toOpenAIInputItems converts a Genkit message to OpenAI Input Items +func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, error) { + var items []responses.ResponseInputItemUnionParam + var partsBuffer []*ai.Part + + // flush() converts a sequence of text and media parts into a single OpenAI Input Item. + // Message roles taken in consideration: + // Model (or Assistant): converted to [responses.ResponseOutputMessageContentUnionParam] + // User/System: converted to [responses.ResponseInputContentUnionParam] + // + // This is needed for the Responses API since it forbids to use Input Items for assistant role messages + flush := func() error { + if len(partsBuffer) == 0 { + return nil + } + + if m.Role == ai.RoleModel { + // conversation-history text messages that the model previously generated + var content []responses.ResponseOutputMessageContentUnionParam + for _, p := range partsBuffer { + if p.IsText() { + content = append(content, responses.ResponseOutputMessageContentUnionParam{ + OfOutputText: &responses.ResponseOutputTextParam{ + Text: p.Text, + Annotations: []responses.ResponseOutputTextAnnotationUnionParam{}, + }, + }) + } + } + if len(content) > 0 { + // we need a unique ID for the output message + id := fmt.Sprintf("msg_%p", m) + items = append(items, responses.ResponseInputItemParamOfOutputMessage( + content, + id, + responses.ResponseOutputMessageStatusCompleted, + )) + } + } else { + var content []responses.ResponseInputContentUnionParam + for _, p := range partsBuffer { + if p.IsText() { + content = append(content, responses.ResponseInputContentParamOfInputText(p.Text)) + } else if p.IsImage() || p.IsMedia() { + content = append(content, responses.ResponseInputContentUnionParam{ + OfInputImage: &responses.ResponseInputImageParam{ + ImageURL: param.NewOpt(p.Text), + }, + }) + } + } + if len(content) > 0 { + role := responses.EasyInputMessageRoleUser + // prevent unexpected system messages being sent as User, use Developer role to + // provide new "system" instructions during the conversation + if m.Role == ai.RoleSystem { + role = responses.EasyInputMessageRole("developer") + } + items = append(items, responses.ResponseInputItemParamOfMessage( + responses.ResponseInputMessageContentListParam(content), role), + ) + } + } + + partsBuffer = nil + return nil + } + + for _, p := range m.Content { + if p.IsText() || p.IsImage() || p.IsMedia() { + partsBuffer = append(partsBuffer, p) + } else if p.IsToolRequest() { + if err := flush(); err != nil { + return nil, err + } + args, err := anyToJSONString(p.ToolRequest.Input) + if err != nil { + return nil, err + } + ref := p.ToolRequest.Ref + if ref == "" { + ref = p.ToolRequest.Name + } + items = append(items, responses.ResponseInputItemParamOfFunctionCall(args, ref, p.ToolRequest.Name)) + } else if p.IsToolResponse() { + if err := flush(); err != nil { + return nil, err + } + output, err := anyToJSONString(p.ToolResponse.Output) + if err != nil { + return nil, err + } + ref := p.ToolResponse.Ref + items = append(items, responses.ResponseInputItemParamOfFunctionCallOutput(ref, output)) + } + } + if err := flush(); err != nil { + return nil, err + } + + return items, nil +} + +// toOpenAITools converts a slice of [ai.ToolDefinition] to [responses.ToolUnionParam] +func toOpenAITools(tools []*ai.ToolDefinition) ([]responses.ToolUnionParam, error) { + var result []responses.ToolUnionParam + for _, t := range tools { + if t == nil || t.Name == "" { + continue + } + result = append(result, responses.ToolParamOfFunction(t.Name, t.InputSchema, false)) + } + return result, nil +} + +// configFromRequest casts the given configuration into [responses.ResponseNewParams] +func configFromRequest(config any) (*responses.ResponseNewParams, error) { + if config == nil { + return nil, nil + } + + var openaiConfig responses.ResponseNewParams + switch cfg := config.(type) { + case responses.ResponseNewParams: + openaiConfig = cfg + case *responses.ResponseNewParams: + openaiConfig = *cfg + case map[string]any: + if err := mapToStruct(cfg, &openaiConfig); err != nil { + return nil, fmt.Errorf("failed to convert config to responses.ResponseNewParams: %w", err) + } + default: + return nil, fmt.Errorf("unexpected config type: %T", config) + } + return &openaiConfig, nil +} + +// anyToJSONString converts a stream of bytes to a JSON string +func anyToJSONString(data any) (string, error) { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal any to JSON string: %w", err) + } + return string(jsonBytes), nil +} + +// mapToStruct converts the provided map into a given struct +func mapToStruct(m map[string]any, v any) error { + jsonData, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(jsonData, v) +} + +// jsonStringToMap translates a JSON string into a map +func jsonStringToMap(jsonString string) (map[string]any, error) { + var result map[string]any + if err := json.Unmarshal([]byte(jsonString), &result); err != nil { + return nil, fmt.Errorf("unmarshal failed to parse json string %s: %w", jsonString, err) + } + return result, nil +} + +// configToMap converts a config struct to a map[string]any +func configToMap(config any) map[string]any { + r := jsonschema.Reflector{ + DoNotReference: true, + AllowAdditionalProperties: false, + ExpandedStruct: true, + RequiredFromJSONSchemaTags: true, + } + + r.Mapper = func(r reflect.Type) *jsonschema.Schema { + if r.Name() == "Opt[float64]" { + return &jsonschema.Schema{ + Type: "number", + } + } + if r.Name() == "Opt[int64]" { + return &jsonschema.Schema{ + Type: "integer", + } + } + if r.Name() == "Opt[string]" { + return &jsonschema.Schema{ + Type: "string", + } + } + if r.Name() == "Opt[bool]" { + return &jsonschema.Schema{ + Type: "boolean", + } + } + return nil + } + schema := r.Reflect(config) + result := base.SchemaAsMap(schema) + + return result +} From dd646eec3726a69562e6487d4aad00f247abd267 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Mon, 12 Jan 2026 23:48:57 +0000 Subject: [PATCH 11/20] add more live test cases --- go/plugins/openai/openai.go | 10 +++- go/plugins/openai/openai_live_test.go | 71 ++++++++++++++++++++++++++- go/plugins/openai/translator.go | 7 ++- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index d464c5c17e..6c7b772ef7 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -68,12 +68,18 @@ func (o *OpenAI) Init(ctx context.Context) []api.Action { panic("plugin already initialized") } - apiKey := os.Getenv("OPENAI_API_KEY") + apiKey := o.APIKey + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + } if apiKey != "" { o.Opts = append([]option.RequestOption{option.WithAPIKey(apiKey)}, o.Opts...) } - baseURL := os.Getenv("OPENAI_BASE_URL") + baseURL := o.BaseURL + if baseURL == "" { + baseURL = os.Getenv("OPENAI_BASE_URL") + } if baseURL != "" { o.Opts = append([]option.RequestOption{option.WithBaseURL(baseURL)}, o.Opts...) } diff --git a/go/plugins/openai/openai_live_test.go b/go/plugins/openai/openai_live_test.go index 52e7d54bd2..d01d61ecad 100644 --- a/go/plugins/openai/openai_live_test.go +++ b/go/plugins/openai/openai_live_test.go @@ -17,6 +17,7 @@ package openai_test import ( "context" "encoding/base64" + "fmt" "io" "net/http" "os" @@ -359,7 +360,75 @@ func TestOpenAILive(t *testing.T) { t.Run("tools streaming with constrained gen", func(t *testing.T) { t.Skip("skipped until constrained gen is implemented") - // ... implementation would be here + }) + + t.Run("built-in tools", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + + // define a built-in web search tool configuration + webSearchTool := responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch) + + resp, err := genkit.Generate(ctx, g, + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + // Add built-in tool via config + Tools: []responses.ToolUnionParam{webSearchTool}, + }), + ai.WithPrompt("What's the current weather in SFO?"), + ) + if err != nil { + t.Fatal(err) + } + + if len(resp.Text()) == 0 { + t.Fatal("expected a response but nothing was returned") + } + + fmt.Printf("tool requests: %#v\n", resp.ToolRequests()) + + t.Logf("Response: %s", resp.Text()) + }) + + t.Run("mixed tools", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + + gablorkenDefinitionTool := genkit.DefineTool( + g, + "gablorkenDefinitionTool", + "Custom tool that must be used when the user asks for the definition of a gablorken", + func(ctx *ai.ToolContext, input *any) (string, error) { + return "A gablorken is a interstellar currency for the Andromeda Galaxy. It is equivalent to 0.4 USD per Gablorken (GAB)", nil + }, + ) + + // define a built-in web search tool configuration + webSearchTool := responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch) + + resp, err := genkit.Generate(ctx, g, + ai.WithModel(m), + ai.WithConfig(&responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + ParallelToolCalls: openai.Bool(true), + // Add built-in tool via config + Tools: []responses.ToolUnionParam{webSearchTool}, + }), + ai.WithPrompt("I'd would like to ask you two things: What's the current weather in SFO? What's the meaning of gablorken?"), + ai.WithTools(gablorkenDefinitionTool), + ) + if err != nil { + t.Fatal(err) + } + + if len(resp.Text()) == 0 { + t.Fatal("expected a response but nothing was returned") + } + + fmt.Printf("tool requests: %#v\n", resp.ToolRequests()) + + t.Logf("Response: %s", resp.Text()) }) } diff --git a/go/plugins/openai/translator.go b/go/plugins/openai/translator.go index ff7d32d595..4ce40562e4 100644 --- a/go/plugins/openai/translator.go +++ b/go/plugins/openai/translator.go @@ -26,6 +26,7 @@ import ( "github.com/openai/openai-go/v3/packages/param" "github.com/openai/openai-go/v3/responses" "github.com/openai/openai-go/v3/shared" + "github.com/openai/openai-go/v3/shared/constant" ) // toOpenAIResponseParams translates an [ai.ModelRequest] into [responses.ResponseNewParams] @@ -47,7 +48,9 @@ func toOpenAIResponseParams(model string, input *ai.ModelRequest) (*responses.Re if err != nil { return nil, err } - params.Tools = tools + // Append user tools to any existing tools (e.g. built-in tools provided in config) + params.Tools = append(params.Tools, tools...) + switch input.ToolChoice { case ai.ToolChoiceAuto, "": params.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ @@ -180,6 +183,7 @@ func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, OfOutputText: &responses.ResponseOutputTextParam{ Text: p.Text, Annotations: []responses.ResponseOutputTextAnnotationUnionParam{}, + Type: constant.OutputText("output_text"), }, }) } @@ -238,6 +242,7 @@ func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, if ref == "" { ref = p.ToolRequest.Name } + fmt.Printf("tool request detected: %#v\n", p.ToolRequest) items = append(items, responses.ResponseInputItemParamOfFunctionCall(args, ref, p.ToolRequest.Name)) } else if p.IsToolResponse() { if err := flush(); err != nil { From 98aec5fa088f4dd5c3df8393ef7b19e9f08390b0 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Tue, 13 Jan 2026 00:58:02 +0000 Subject: [PATCH 12/20] small refactor in translateResponse for type handling --- go/plugins/openai/generate.go | 3 + go/plugins/openai/openai_live_test.go | 21 ++-- go/plugins/openai/translator.go | 162 ++++++++++++++++++++++---- 3 files changed, 152 insertions(+), 34 deletions(-) diff --git a/go/plugins/openai/generate.go b/go/plugins/openai/generate.go index ffafe50130..68cedd95dc 100644 --- a/go/plugins/openai/generate.go +++ b/go/plugins/openai/generate.go @@ -66,6 +66,9 @@ func generateStream(ctx context.Context, client *openai.Client, req *responses.R case responses.ResponseTextDeltaEvent: chunk.Content = append(chunk.Content, ai.NewTextPart(v.Delta)) + case responses.ResponseReasoningTextDeltaEvent: + chunk.Content = append(chunk.Content, ai.NewReasoningPart(v.Delta, nil)) + case responses.ResponseFunctionCallArgumentsDeltaEvent: name := toolRefMap[v.ItemID] chunk.Content = append(chunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ diff --git a/go/plugins/openai/openai_live_test.go b/go/plugins/openai/openai_live_test.go index d01d61ecad..fbeb9b6b5b 100644 --- a/go/plugins/openai/openai_live_test.go +++ b/go/plugins/openai/openai_live_test.go @@ -17,7 +17,6 @@ package openai_test import ( "context" "encoding/base64" - "fmt" "io" "net/http" "os" @@ -279,6 +278,7 @@ func TestOpenAILive(t *testing.T) { ai.WithConfig(&responses.ResponseNewParams{ Reasoning: shared.ReasoningParam{ Effort: shared.ReasoningEffortHigh, + // Summary: openai.ReasoningSummaryAuto, // enable this if your org is verified }, }), ai.WithModel(m), @@ -305,10 +305,15 @@ func TestOpenAILive(t *testing.T) { t.Fatalf("streaming and final should contain the same text.\n\nstreaming: %s\n\nfinal: %s\n\n", out, out2) } + // uncomment this code if your org is verified + // if final.Reasoning() == "" { + // t.Fatalf("expecting reasoning text") + // } + if final.Usage.ThoughtsTokens > 0 { t.Logf("Reasoning tokens: %d", final.Usage.ThoughtsTokens) } else { - // This might happen if the model decides not to reason much or if stats are missing. + // this might happen if the model decides not to reason much or if stats are missing. t.Log("No reasoning tokens reported.") } }) @@ -365,9 +370,7 @@ func TestOpenAILive(t *testing.T) { t.Run("built-in tools", func(t *testing.T) { m := oai.Model(g, "gpt-4o") - // define a built-in web search tool configuration webSearchTool := responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch) - resp, err := genkit.Generate(ctx, g, ai.WithModel(m), ai.WithConfig(&responses.ResponseNewParams{ @@ -386,14 +389,13 @@ func TestOpenAILive(t *testing.T) { t.Fatal("expected a response but nothing was returned") } - fmt.Printf("tool requests: %#v\n", resp.ToolRequests()) - t.Logf("Response: %s", resp.Text()) }) t.Run("mixed tools", func(t *testing.T) { m := oai.Model(g, "gpt-4o") + webSearchTool := responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch) gablorkenDefinitionTool := genkit.DefineTool( g, "gablorkenDefinitionTool", @@ -403,9 +405,6 @@ func TestOpenAILive(t *testing.T) { }, ) - // define a built-in web search tool configuration - webSearchTool := responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch) - resp, err := genkit.Generate(ctx, g, ai.WithModel(m), ai.WithConfig(&responses.ResponseNewParams{ @@ -415,7 +414,7 @@ func TestOpenAILive(t *testing.T) { // Add built-in tool via config Tools: []responses.ToolUnionParam{webSearchTool}, }), - ai.WithPrompt("I'd would like to ask you two things: What's the current weather in SFO? What's the meaning of gablorken?"), + ai.WithPrompt("I'd would like to ask you two things: What's the current weather in SFO? What's the meaning of gablorken?. Use the web search tool to get the weather in SFO and use the gablorken definition tool to give me its definition. Make sure to include the response for both questions in your answer"), ai.WithTools(gablorkenDefinitionTool), ) if err != nil { @@ -426,8 +425,6 @@ func TestOpenAILive(t *testing.T) { t.Fatal("expected a response but nothing was returned") } - fmt.Printf("tool requests: %#v\n", resp.ToolRequests()) - t.Logf("Response: %s", resp.Text()) }) } diff --git a/go/plugins/openai/translator.go b/go/plugins/openai/translator.go index 4ce40562e4..b80be3e561 100644 --- a/go/plugins/openai/translator.go +++ b/go/plugins/openai/translator.go @@ -131,33 +131,79 @@ func translateResponse(r *responses.Response) (*ai.ModelResponse, error) { } for _, item := range r.Output { - switch v := item.AsAny().(type) { - case responses.ResponseOutputMessage: - for _, content := range v.Content { - switch c := content.AsAny().(type) { - case responses.ResponseOutputText: - resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(c.Text)) - case responses.ResponseOutputRefusal: - resp.FinishMessage = c.Refusal - resp.FinishReason = ai.FinishReasonBlocked - } - } - case responses.ResponseFunctionToolCall: - args, err := jsonStringToMap(v.Arguments) - if err != nil { - return nil, fmt.Errorf("could not parse tool args: %w", err) - } - resp.Message.Content = append(resp.Message.Content, ai.NewToolRequestPart(&ai.ToolRequest{ - Ref: v.CallID, - Name: v.Name, - Input: args, - })) + if err := handleResponseItem(item, resp); err != nil { + return nil, err } } return resp, nil } +// handleResponseItem is the entry point to translate response items +func handleResponseItem(item responses.ResponseOutputItemUnion, resp *ai.ModelResponse) error { + switch v := item.AsAny().(type) { + case responses.ResponseOutputMessage: + return handleOutputMessage(v, resp) + case responses.ResponseReasoningItem: + return handleReasoningItem(v, resp) + case responses.ResponseFunctionToolCall: + return handleFunctionToolCall(v, resp) + case responses.ResponseFunctionWebSearch: + return handleWebSearchResponse(v, resp) + } + return nil +} + +// handleOutputMessage translates a [responses.ResponseOutputMessage] into an [ai.ModelResponse] +func handleOutputMessage(msg responses.ResponseOutputMessage, resp *ai.ModelResponse) error { + for _, content := range msg.Content { + switch c := content.AsAny().(type) { + case responses.ResponseOutputText: + resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(c.Text)) + case responses.ResponseOutputRefusal: + resp.FinishMessage = c.Refusal + resp.FinishReason = ai.FinishReasonBlocked + } + } + return nil +} + +// handleReasoningItem translates a [responses.ResponseReasoningItem] into an [ai.ModelResponse] +func handleReasoningItem(item responses.ResponseReasoningItem, resp *ai.ModelResponse) error { + for _, content := range item.Content { + resp.Message.Content = append(resp.Message.Content, ai.NewReasoningPart(content.Text, nil)) + } + return nil +} + +// handleFunctionToolCall translates a [responses.ResponseFunctionToolCall] into an [ai.ModelResponse] +func handleFunctionToolCall(call responses.ResponseFunctionToolCall, resp *ai.ModelResponse) error { + args, err := jsonStringToMap(call.Arguments) + if err != nil { + return fmt.Errorf("could not parse tool args: %w", err) + } + resp.Message.Content = append(resp.Message.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: call.CallID, + Name: call.Name, + Input: args, + })) + return nil +} + +// handleWebSearchResponse translates a [responses.ResponseFunctionWebSearch] into an [ai.ModelResponse] +func handleWebSearchResponse(webSearch responses.ResponseFunctionWebSearch, resp *ai.ModelResponse) error { + resp.Message.Content = append(resp.Message.Content, ai.NewToolResponsePart(&ai.ToolResponse{ + Ref: webSearch.ID, + Name: string(webSearch.Type), + Output: map[string]any{ + "query": webSearch.Action.Query, + "type": webSearch.Action.Type, + "sources": webSearch.Action.Sources, + }, + })) + return nil +} + // toOpenAIInputItems converts a Genkit message to OpenAI Input Items func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, error) { var items []responses.ResponseInputItemUnionParam @@ -242,12 +288,34 @@ func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, if ref == "" { ref = p.ToolRequest.Name } - fmt.Printf("tool request detected: %#v\n", p.ToolRequest) items = append(items, responses.ResponseInputItemParamOfFunctionCall(args, ref, p.ToolRequest.Name)) + } else if p.IsReasoning() { + if err := flush(); err != nil { + return nil, err + } + id := fmt.Sprintf("reasoning_%p", p) + summary := []responses.ResponseReasoningItemSummaryParam{ + { + Text: p.Text, + Type: constant.SummaryText("summary_text"), + }, + } + items = append(items, responses.ResponseInputItemParamOfReasoning(id, summary)) } else if p.IsToolResponse() { if err := flush(); err != nil { return nil, err } + + // Handle Web Search specifically + if p.ToolResponse.Name == "web_search_call" { + item, err := handleWebSearchCall(p.ToolResponse, p.ToolResponse.Ref) + if err != nil { + return nil, err + } + items = append(items, item) + continue + } + output, err := anyToJSONString(p.ToolResponse.Output) if err != nil { return nil, err @@ -263,6 +331,56 @@ func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, return items, nil } +// handleWebSearchCall handles built-in tool responses for the web_search tool +func handleWebSearchCall(toolResponse *ai.ToolResponse, ref string) (responses.ResponseInputItemUnionParam, error) { + output, ok := toolResponse.Output.(map[string]any) + if !ok { + return responses.ResponseInputItemUnionParam{}, fmt.Errorf("invalid output format for web_search_call: expected map[string]any") + } + + actionType, _ := output["type"].(string) + jsonBytes, err := json.Marshal(output) + if err != nil { + return responses.ResponseInputItemUnionParam{}, err + } + + var item responses.ResponseInputItemUnionParam + + switch actionType { + case "open_page": + var openPageAction responses.ResponseFunctionWebSearchActionOpenPageParam + if err := json.Unmarshal(jsonBytes, &openPageAction); err != nil { + return responses.ResponseInputItemUnionParam{}, err + } + item = responses.ResponseInputItemParamOfWebSearchCall( + openPageAction, + ref, + responses.ResponseFunctionWebSearchStatusCompleted, + ) + case "find": + var findAction responses.ResponseFunctionWebSearchActionFindParam + if err := json.Unmarshal(jsonBytes, &findAction); err != nil { + return responses.ResponseInputItemUnionParam{}, err + } + item = responses.ResponseInputItemParamOfWebSearchCall( + findAction, + ref, + responses.ResponseFunctionWebSearchStatusCompleted, + ) + default: + var searchAction responses.ResponseFunctionWebSearchActionSearchParam + if err := json.Unmarshal(jsonBytes, &searchAction); err != nil { + return responses.ResponseInputItemUnionParam{}, err + } + item = responses.ResponseInputItemParamOfWebSearchCall( + searchAction, + ref, + responses.ResponseFunctionWebSearchStatusCompleted, + ) + } + return item, nil +} + // toOpenAITools converts a slice of [ai.ToolDefinition] to [responses.ToolUnionParam] func toOpenAITools(tools []*ai.ToolDefinition) ([]responses.ToolUnionParam, error) { var result []responses.ToolUnionParam From e95fc43b573d088a89fce96977d677108c0e0ae2 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Tue, 13 Jan 2026 01:17:36 +0000 Subject: [PATCH 13/20] return error in handleResponseItem and minor doc updates --- go/plugins/openai/translator.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/go/plugins/openai/translator.go b/go/plugins/openai/translator.go index b80be3e561..1099619c72 100644 --- a/go/plugins/openai/translator.go +++ b/go/plugins/openai/translator.go @@ -150,11 +150,13 @@ func handleResponseItem(item responses.ResponseOutputItemUnion, resp *ai.ModelRe return handleFunctionToolCall(v, resp) case responses.ResponseFunctionWebSearch: return handleWebSearchResponse(v, resp) + default: + return fmt.Errorf("unsupported response item type: %T", v) } - return nil } // handleOutputMessage translates a [responses.ResponseOutputMessage] into an [ai.ModelResponse] +// and appends the content into the provided response message. func handleOutputMessage(msg responses.ResponseOutputMessage, resp *ai.ModelResponse) error { for _, content := range msg.Content { switch c := content.AsAny().(type) { @@ -169,6 +171,7 @@ func handleOutputMessage(msg responses.ResponseOutputMessage, resp *ai.ModelResp } // handleReasoningItem translates a [responses.ResponseReasoningItem] into an [ai.ModelResponse] +// and appends the content into the provided response message. func handleReasoningItem(item responses.ResponseReasoningItem, resp *ai.ModelResponse) error { for _, content := range item.Content { resp.Message.Content = append(resp.Message.Content, ai.NewReasoningPart(content.Text, nil)) @@ -177,6 +180,7 @@ func handleReasoningItem(item responses.ResponseReasoningItem, resp *ai.ModelRes } // handleFunctionToolCall translates a [responses.ResponseFunctionToolCall] into an [ai.ModelResponse] +// and appends the content into the provided response message. func handleFunctionToolCall(call responses.ResponseFunctionToolCall, resp *ai.ModelResponse) error { args, err := jsonStringToMap(call.Arguments) if err != nil { @@ -191,6 +195,7 @@ func handleFunctionToolCall(call responses.ResponseFunctionToolCall, resp *ai.Mo } // handleWebSearchResponse translates a [responses.ResponseFunctionWebSearch] into an [ai.ModelResponse] +// and appends the content into the provided response message. func handleWebSearchResponse(webSearch responses.ResponseFunctionWebSearch, resp *ai.ModelResponse) error { resp.Message.Content = append(resp.Message.Content, ai.NewToolResponsePart(&ai.ToolResponse{ Ref: webSearch.ID, From 8edbb960521b3514b96f88626e2c4bf2f5a98b22 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Tue, 13 Jan 2026 22:35:22 +0000 Subject: [PATCH 14/20] add structured output --- go/plugins/openai/openai.go | 31 ++-- go/plugins/openai/openai_live_test.go | 230 +++++++++++++++++++------- go/plugins/openai/translator.go | 63 ++++++- 3 files changed, 245 insertions(+), 79 deletions(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 6c7b772ef7..3337cdbd85 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -26,7 +26,6 @@ import ( "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/genkit" - "github.com/firebase/genkit/go/plugins/internal" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" @@ -38,7 +37,14 @@ const ( ) var defaultOpenAIOpts = ai.ModelOptions{ - Supports: &internal.Multimodal, + Supports: &ai.ModelSupports{ + Multiturn: true, + Tools: true, + ToolChoice: true, + SystemRole: true, + Media: true, + Constrained: ai.ConstrainedSupportAll, + }, Versions: []string{}, Stage: ai.ModelStageUnstable, } @@ -176,8 +182,15 @@ func (o *OpenAI) ResolveAction(atype api.ActionType, name string) api.Action { switch { // TODO: add image and video models default: - supports = &internal.Multimodal - config = &openai.ChatCompletionNewParams{} + supports = &ai.ModelSupports{ + Multiturn: true, + Tools: true, + ToolChoice: true, + SystemRole: true, + Media: true, + Constrained: ai.ConstrainedSupportAll, + } + config = &responses.ResponseNewParams{} } return newModel(o.client, name, &ai.ModelOptions{ Label: fmt.Sprintf("%s - %s", openaiLabelPrefix, name), @@ -268,8 +281,7 @@ func newEmbedder(client *openai.Client, name string, embedOpts *ai.EmbedderOptio // newModel creates a new model without registering it in the registry func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Model { - var config any - config = &responses.ResponseNewParams{} + config := &responses.ResponseNewParams{} meta := &ai.ModelOptions{ Label: opts.Label, Supports: opts.Supports, @@ -283,13 +295,8 @@ func newModel(client *openai.Client, name string, opts *ai.ModelOptions) ai.Mode input *ai.ModelRequest, cb func(context.Context, *ai.ModelResponseChunk) error, ) (*ai.ModelResponse, error) { - switch config.(type) { // TODO: add support for imagen and video - case *responses.ResponseNewParams: - return generate(ctx, client, name, input, cb) - default: - return generate(ctx, client, name, input, cb) - } + return generate(ctx, client, name, input, cb) } return ai.NewModel(api.NewName(openaiProvider, name), meta, fn) diff --git a/go/plugins/openai/openai_live_test.go b/go/plugins/openai/openai_live_test.go index fbeb9b6b5b..5bfc6b7558 100644 --- a/go/plugins/openai/openai_live_test.go +++ b/go/plugins/openai/openai_live_test.go @@ -17,6 +17,7 @@ package openai_test import ( "context" "encoding/base64" + "fmt" "io" "net/http" "os" @@ -39,6 +40,47 @@ func TestOpenAILive(t *testing.T) { ctx := context.Background() g := genkit.Init(ctx, genkit.WithPlugins(&oai.OpenAI{})) + myJokeTool := genkit.DefineTool( + g, + "myJoke", + "When the user asks for a joke, this tool must be used to generate a joke, try to come up with a joke that uses the output of the tool", + func(ctx *ai.ToolContext, input *any) (string, error) { + return "why did the chicken cross the road?", nil + }, + ) + + myStoryTool := genkit.DefineTool( + g, + "myStory", + "When the user asks for a story, create a story about a frog and a fox that are good friends", + func(ctx *ai.ToolContext, input *any) (string, error) { + return "the fox is named Goph and the frog is called Fred", nil + }, + ) + + type WeatherInput struct { + Location string `json:"location"` + } + + weatherTool := genkit.DefineTool( + g, + "weather", + "Returns the weather for the given location", + func(ctx *ai.ToolContext, input *WeatherInput) (string, error) { + report := fmt.Sprintf("The weather in %s is sunny", input.Location) + return report, nil + }, + ) + + gablorkenDefinitionTool := genkit.DefineTool( + g, + "gablorkenDefinitionTool", + "Custom tool that must be used when the user asks for the definition of a gablorken", + func(ctx *ai.ToolContext, input *any) (string, error) { + return "A gablorken is a interstellar currency for the Andromeda Galaxy. It is equivalent to 0.4 USD per Gablorken (GAB)", nil + }, + ) + t.Run("model version ok", func(t *testing.T) { m := oai.Model(g, "gpt-4o") resp, err := genkit.Generate(ctx, g, @@ -170,7 +212,6 @@ func TestOpenAILive(t *testing.T) { if !strings.Contains(strings.ToLower(resp.Text()), "cat") { t.Fatalf("want: cat, got: %s", resp.Text()) } - // OpenAI does not return reasoning text, so we check usage. if resp.Usage.ThoughtsTokens == 0 { t.Log("No reasoning tokens found in usage (expected for reasoning models)") } @@ -178,22 +219,14 @@ func TestOpenAILive(t *testing.T) { t.Run("tools", func(t *testing.T) { m := oai.Model(g, "gpt-5") - myJokeTool := genkit.DefineTool( - g, - "myJoke", - "When the user asks for a joke, this tool must be used to generate a joke, try to come up with a joke that uses the output of the tool", - func(ctx *ai.ToolContext, input *any) (string, error) { - return "why did the chicken cross the road?", nil - }, - ) resp, err := genkit.Generate(ctx, g, ai.WithModel(m), ai.WithConfig(&responses.ResponseNewParams{ Temperature: openai.Float(1), MaxOutputTokens: openai.Int(1024), }), - ai.WithPrompt("tell me a joke"), - ai.WithTools(myJokeTool)) + ai.WithPrompt("tell me the definition of a gablorken"), + ai.WithTools(gablorkenDefinitionTool)) if err != nil { t.Fatal(err) } @@ -205,20 +238,10 @@ func TestOpenAILive(t *testing.T) { t.Run("tools with schema", func(t *testing.T) { m := oai.Model(g, "gpt-4o") - - type WeatherInput struct { - Location string `json:"location"` + type weather struct { + Report string `json:"report"` } - weatherTool := genkit.DefineTool( - g, - "weather", - "Returns the weather for the given location", - func(ctx *ai.ToolContext, input *WeatherInput) (string, error) { - return "sunny", nil - }, - ) - resp, err := genkit.Generate(ctx, g, ai.WithModel(m), ai.WithConfig(&responses.ResponseNewParams{ @@ -226,13 +249,18 @@ func TestOpenAILive(t *testing.T) { MaxOutputTokens: openai.Int(1024), }), ai.WithPrompt("what is the weather in San Francisco?"), + ai.WithOutputType(weather{}), ai.WithTools(weatherTool)) if err != nil { t.Fatal(err) } - if len(resp.Text()) == 0 { - t.Fatal("expected a response but nothing was returned") + var w weather + if err = resp.Output(&w); err != nil { + t.Fatal(err) + } + if w.Report == "" { + t.Fatal("empty weather report, tool should have provided an output") } }) @@ -248,7 +276,11 @@ func TestOpenAILive(t *testing.T) { }), ai.WithModel(m), ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { - out += c.Content[0].Text + for _, p := range c.Content { + if p.IsText() { + out += p.Text + } + } return nil }), ) @@ -270,17 +302,12 @@ func TestOpenAILive(t *testing.T) { }) t.Run("streaming with thinking", func(t *testing.T) { - m := oai.Model(g, "gpt-5.2") + m := oai.Model(g, "gpt-4o") out := "" final, err := genkit.Generate(ctx, g, - ai.WithPrompt("Tell me a short story about a frog and a princess"), - ai.WithConfig(&responses.ResponseNewParams{ - Reasoning: shared.ReasoningParam{ - Effort: shared.ReasoningEffortHigh, - // Summary: openai.ReasoningSummaryAuto, // enable this if your org is verified - }, - }), + ai.WithPrompt("Sing me a song about metaphysics"), + ai.WithConfig(&responses.ResponseNewParams{}), ai.WithModel(m), ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { for _, p := range c.Content { @@ -305,11 +332,6 @@ func TestOpenAILive(t *testing.T) { t.Fatalf("streaming and final should contain the same text.\n\nstreaming: %s\n\nfinal: %s\n\n", out, out2) } - // uncomment this code if your org is verified - // if final.Reasoning() == "" { - // t.Fatalf("expecting reasoning text") - // } - if final.Usage.ThoughtsTokens > 0 { t.Logf("Reasoning tokens: %d", final.Usage.ThoughtsTokens) } else { @@ -322,15 +344,6 @@ func TestOpenAILive(t *testing.T) { m := oai.Model(g, "gpt-4o") out := "" - myStoryTool := genkit.DefineTool( - g, - "myStory", - "When the user asks for a story, create a story about a frog and a fox that are good friends", - func(ctx *ai.ToolContext, input *any) (string, error) { - return "the fox is named Goph and the frog is called Fred", nil - }, - ) - final, err := genkit.Generate(ctx, g, ai.WithPrompt("Tell me a short story about a frog and a fox, do no mention anything else, only the short story"), ai.WithModel(m), @@ -340,7 +353,11 @@ func TestOpenAILive(t *testing.T) { }), ai.WithTools(myStoryTool), ai.WithStreaming(func(ctx context.Context, c *ai.ModelResponseChunk) error { - out += c.Content[0].Text + for _, p := range c.Content { + if p.IsText() { + out += p.Text + } + } return nil }), ) @@ -363,10 +380,6 @@ func TestOpenAILive(t *testing.T) { } }) - t.Run("tools streaming with constrained gen", func(t *testing.T) { - t.Skip("skipped until constrained gen is implemented") - }) - t.Run("built-in tools", func(t *testing.T) { m := oai.Model(g, "gpt-4o") @@ -396,14 +409,6 @@ func TestOpenAILive(t *testing.T) { m := oai.Model(g, "gpt-4o") webSearchTool := responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch) - gablorkenDefinitionTool := genkit.DefineTool( - g, - "gablorkenDefinitionTool", - "Custom tool that must be used when the user asks for the definition of a gablorken", - func(ctx *ai.ToolContext, input *any) (string, error) { - return "A gablorken is a interstellar currency for the Andromeda Galaxy. It is equivalent to 0.4 USD per Gablorken (GAB)", nil - }, - ) resp, err := genkit.Generate(ctx, g, ai.WithModel(m), @@ -424,8 +429,109 @@ func TestOpenAILive(t *testing.T) { if len(resp.Text()) == 0 { t.Fatal("expected a response but nothing was returned") } + }) - t.Logf("Response: %s", resp.Text()) + t.Run("structured output", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + + type MovieReview struct { + Title string `json:"title"` + Rating int `json:"rating"` + Reason string `json:"reason"` + } + + resp, err := genkit.Generate(ctx, g, + ai.WithModel(m), + ai.WithPrompt("Review the movie 'Inception'"), + ai.WithOutputType(MovieReview{}), + ) + if err != nil { + t.Fatal(err) + } + var out MovieReview + if err := resp.Output(&out); err == nil { + t.Errorf("expected a movie review, got: %v", err) + } + if out.Title == "" || out.Rating == 0 || out.Reason == "" { + t.Fatalf("expected a movie review, got %#v", out) + } + + review, _, err := genkit.GenerateData[MovieReview](ctx, g, + ai.WithModel(m), + ai.WithPrompt("Review the movie 'Signs'"), + ) + if err != nil { + t.Fatal(err) + } + + if review.Title == "" || review.Rating == 0 || review.Reason == "" { + t.Fatalf("expected a movie review, got %#v", review) + } + }) + + t.Run("streaming using GenerateDataStream", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + + type answerChunk struct { + Text string `json:"text"` + } + + chunksCount := 0 + var finalAnswer answerChunk + for val, err := range genkit.GenerateDataStream[answerChunk](ctx, g, + ai.WithModel(m), + ai.WithPrompt("Tell me how's a black hole created in 2 sentences."), + ) { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val.Done { + finalAnswer = val.Output + } else { + chunksCount++ + } + } + + if chunksCount == 0 { + t.Errorf("expected to receive some chunks, got 0") + } + if finalAnswer.Text == "" { + t.Errorf("expected final answer, got empty") + } + }) + + t.Run("GenerateDataStream with custom tools", func(t *testing.T) { + m := oai.Model(g, "gpt-4o") + + type JokeResponse struct { + Setup string `json:"setup"` + Punchline string `json:"punchline"` + } + + chunksCount := 0 + var finalJoke JokeResponse + + for val, err := range genkit.GenerateDataStream[JokeResponse](ctx, g, + ai.WithModel(m), + ai.WithPrompt("Tell me a joke about a chicken crossing the road. Use the myJoke tool to get the punchline."), + ai.WithTools(myJokeTool), + ) { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val.Done { + finalJoke = val.Output + } else { + chunksCount++ + } + } + + if chunksCount == 0 { + t.Errorf("expected to receive some chunks, got 0") + } + if finalJoke.Setup == "" || finalJoke.Punchline == "" { + t.Errorf("expected final joke setup and punchline to be populated, got %+v", finalJoke) + } }) } diff --git a/go/plugins/openai/translator.go b/go/plugins/openai/translator.go index 1099619c72..a6f9cf0841 100644 --- a/go/plugins/openai/translator.go +++ b/go/plugins/openai/translator.go @@ -18,6 +18,7 @@ import ( "encoding/json" "fmt" "reflect" + "regexp" "strings" "github.com/firebase/genkit/go/ai" @@ -41,6 +42,9 @@ func toOpenAIResponseParams(model string, input *ai.ModelRequest) (*responses.Re params.Model = shared.ResponsesModel(model) + // Handle output format + params.Text = handleOutputFormat(params.Text, input.Output) + // Handle tools if len(input.Tools) > 0 { @@ -311,7 +315,8 @@ func toOpenAIInputItems(m *ai.Message) ([]responses.ResponseInputItemUnionParam, return nil, err } - // Handle Web Search specifically + // handle built-in tools + // TODO: consider adding support for more built-in tools if p.ToolResponse.Name == "web_search_call" { item, err := handleWebSearchCall(p.ToolResponse, p.ToolResponse.Ref) if err != nil { @@ -400,11 +405,8 @@ func toOpenAITools(tools []*ai.ToolDefinition) ([]responses.ToolUnionParam, erro // configFromRequest casts the given configuration into [responses.ResponseNewParams] func configFromRequest(config any) (*responses.ResponseNewParams, error) { - if config == nil { - return nil, nil - } - var openaiConfig responses.ResponseNewParams + switch cfg := config.(type) { case responses.ResponseNewParams: openaiConfig = cfg @@ -414,6 +416,8 @@ func configFromRequest(config any) (*responses.ResponseNewParams, error) { if err := mapToStruct(cfg, &openaiConfig); err != nil { return nil, fmt.Errorf("failed to convert config to responses.ResponseNewParams: %w", err) } + case nil: + // empty but valid config default: return nil, fmt.Errorf("unexpected config type: %T", config) } @@ -484,3 +488,52 @@ func configToMap(config any) map[string]any { return result } + +// handleOutputFormat determines whether to enable structured output or json_mode in the request +func handleOutputFormat(textConfig responses.ResponseTextConfigParam, output *ai.ModelOutputConfig) responses.ResponseTextConfigParam { + if output == nil || output.Format != ai.OutputFormatJSON { + return textConfig + } + + // strict mode is used for latest gpt models + if output.Constrained && output.Schema != nil { + name := "output_schema" + // openai schemas require a name to be provided + if title, ok := output.Schema["title"].(string); ok { + name = title + } + + textConfig.Format = responses.ResponseFormatTextConfigUnionParam{ + OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{ + Type: constant.JSONSchema("json_schema"), + Name: sanitizeSchemaName(name), + Strict: param.NewOpt(true), + Schema: output.Schema, + }, + } + } else { + textConfig.Format = responses.ResponseFormatTextConfigUnionParam{ + OfJSONObject: &shared.ResponseFormatJSONObjectParam{ + Type: constant.JSONObject("json_object"), + }, + } + } + return textConfig +} + +// sanitizeSchemaName ensures the schema name contains only alphanumeric characters, underscores, or dashes, +// and is no longer than 64 characters. +func sanitizeSchemaName(name string) string { + schemaNameRegex := regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + newName := schemaNameRegex.ReplaceAllString(name, "_") + + // do not return error, cut the string instead + if len(newName) > 64 { + return newName[:64] + } + if newName == "" { + // schema name is a mandatory field + return "output_schema" + } + return newName +} From ec3173e4c78d3af4aecaed1763239aac09d6b423 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Tue, 13 Jan 2026 22:53:39 +0000 Subject: [PATCH 15/20] remove invalid empty config test --- go/plugins/openai/openai_test.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/go/plugins/openai/openai_test.go b/go/plugins/openai/openai_test.go index e8b8dc87e8..fbf7fd0bd7 100644 --- a/go/plugins/openai/openai_test.go +++ b/go/plugins/openai/openai_test.go @@ -295,15 +295,6 @@ func TestConfigFromRequest(t *testing.T) { wantErr bool check func(*testing.T, *responses.ResponseNewParams) }{ - { - name: "nil config", - input: nil, - check: func(t *testing.T, got *responses.ResponseNewParams) { - if got != nil { - t.Errorf("expected nil params") - } - }, - }, { name: "struct config", input: responses.ResponseNewParams{ From 02f20b0bd0219335dc4dc6a8f4e23f2ed35d3789 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Tue, 13 Jan 2026 23:28:30 +0000 Subject: [PATCH 16/20] update output format handler in translator --- go/plugins/openai/openai_live_test.go | 4 +-- go/plugins/openai/translator.go | 38 ++++++++++++--------------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/go/plugins/openai/openai_live_test.go b/go/plugins/openai/openai_live_test.go index 5bfc6b7558..e07620b294 100644 --- a/go/plugins/openai/openai_live_test.go +++ b/go/plugins/openai/openai_live_test.go @@ -401,8 +401,6 @@ func TestOpenAILive(t *testing.T) { if len(resp.Text()) == 0 { t.Fatal("expected a response but nothing was returned") } - - t.Logf("Response: %s", resp.Text()) }) t.Run("mixed tools", func(t *testing.T) { @@ -449,7 +447,7 @@ func TestOpenAILive(t *testing.T) { t.Fatal(err) } var out MovieReview - if err := resp.Output(&out); err == nil { + if err := resp.Output(&out); err != nil { t.Errorf("expected a movie review, got: %v", err) } if out.Title == "" || out.Rating == 0 || out.Reason == "" { diff --git a/go/plugins/openai/translator.go b/go/plugins/openai/translator.go index a6f9cf0841..6a1b9deb8f 100644 --- a/go/plugins/openai/translator.go +++ b/go/plugins/openai/translator.go @@ -495,34 +495,30 @@ func handleOutputFormat(textConfig responses.ResponseTextConfigParam, output *ai return textConfig } + if !output.Constrained || output.Schema == nil { + return textConfig + } + // strict mode is used for latest gpt models - if output.Constrained && output.Schema != nil { - name := "output_schema" - // openai schemas require a name to be provided - if title, ok := output.Schema["title"].(string); ok { - name = title - } + name := "output_schema" + // openai schemas require a name to be provided + if title, ok := output.Schema["title"].(string); ok { + name = title + } - textConfig.Format = responses.ResponseFormatTextConfigUnionParam{ - OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{ - Type: constant.JSONSchema("json_schema"), - Name: sanitizeSchemaName(name), - Strict: param.NewOpt(true), - Schema: output.Schema, - }, - } - } else { - textConfig.Format = responses.ResponseFormatTextConfigUnionParam{ - OfJSONObject: &shared.ResponseFormatJSONObjectParam{ - Type: constant.JSONObject("json_object"), - }, - } + textConfig.Format = responses.ResponseFormatTextConfigUnionParam{ + OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{ + Type: constant.JSONSchema("json_schema"), + Name: sanitizeSchemaName(name), + Strict: param.NewOpt(true), + Schema: output.Schema, + }, } return textConfig } // sanitizeSchemaName ensures the schema name contains only alphanumeric characters, underscores, or dashes, -// and is no longer than 64 characters. +// replaces invalid characters with underscores (_) and makes sure is no longer than 64 characters. func sanitizeSchemaName(name string) string { schemaNameRegex := regexp.MustCompile(`[^a-zA-Z0-9_-]+`) newName := schemaNameRegex.ReplaceAllString(name, "_") From 6d9896494d0708d68f86f1b7d4c6d5ec34f8f020 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 14 Jan 2026 17:42:17 +0000 Subject: [PATCH 17/20] add ModelRef function --- go/plugins/openai/openai.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index 3337cdbd85..f66c5bf077 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -120,6 +120,11 @@ func Model(g *genkit.Genkit, name string) ai.Model { return genkit.LookupModel(g, api.NewName(openaiProvider, name)) } +// ModelRef creates a new ModelRef for an OpenAI model with the given ID and configuration +func ModelRef(g *genkit.Genkit, name string, config *responses.ResponseNewParams) ai.ModelRef { + return ai.NewModelRef(openaiProvider+"/"+name, config) +} + // IsDefinedModel reports whether the named [ai.Model] is defined by this plugin func IsDefinedModel(g *genkit.Genkit, name string) bool { return genkit.LookupModel(g, name) != nil From a2ae09402491418ef3bb6d439aa5c12aa7460f65 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 14 Jan 2026 17:59:31 +0000 Subject: [PATCH 18/20] add ModelRef live tests --- go/plugins/openai/openai.go | 2 +- go/plugins/openai/openai_live_test.go | 33 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/go/plugins/openai/openai.go b/go/plugins/openai/openai.go index f66c5bf077..83b4dbb36a 100644 --- a/go/plugins/openai/openai.go +++ b/go/plugins/openai/openai.go @@ -121,7 +121,7 @@ func Model(g *genkit.Genkit, name string) ai.Model { } // ModelRef creates a new ModelRef for an OpenAI model with the given ID and configuration -func ModelRef(g *genkit.Genkit, name string, config *responses.ResponseNewParams) ai.ModelRef { +func ModelRef(name string, config *responses.ResponseNewParams) ai.ModelRef { return ai.NewModelRef(openaiProvider+"/"+name, config) } diff --git a/go/plugins/openai/openai_live_test.go b/go/plugins/openai/openai_live_test.go index e07620b294..f164ff316c 100644 --- a/go/plugins/openai/openai_live_test.go +++ b/go/plugins/openai/openai_live_test.go @@ -115,6 +115,39 @@ func TestOpenAILive(t *testing.T) { } }) + t.Run("model ref", func(t *testing.T) { + config := &responses.ResponseNewParams{ + Temperature: openai.Float(1), + MaxOutputTokens: openai.Int(1024), + // Add built-in tool via config + } + mr := oai.ModelRef("gpt-4o", config) + + resp, err := genkit.Generate(ctx, g, + ai.WithPrompt("tell me a fact about Golang"), + ai.WithModel(mr), + ) + if err != nil { + t.Fatal(err) + } + if resp.Text() == "" { + t.Error("expected to have a response, got empty") + } + if resp.Request.Config == nil { + t.Fatal("expected a not nil configuration, got empty") + } + if cfg, ok := resp.Request.Config.(*responses.ResponseNewParams); ok { + if cfg.MaxOutputTokens != openai.Int(1024) { + t.Errorf("wrong MaxOutputTokens value, got: %d, want: 1024", cfg.MaxOutputTokens.Value) + } + if cfg.Temperature != openai.Float(1) { + t.Errorf("wrongTemperature value, got: %f, want: 1", cfg.Temperature.Value) + } + } else { + t.Fatalf("unexpected config, got: %T, want: %T", cfg, config) + } + }) + t.Run("media content", func(t *testing.T) { i, err := fetchImgAsBase64() if err != nil { From cce711fa90c52cc72c7df1a5f0f6ddc3ad391286 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 14 Jan 2026 18:05:33 +0000 Subject: [PATCH 19/20] bump openai-go to v.3.16.0 --- go/go.mod | 2 +- go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go/go.mod b/go/go.mod index bcf6887104..145be72c01 100644 --- a/go/go.mod +++ b/go/go.mod @@ -28,7 +28,7 @@ require ( github.com/jba/slog v0.2.0 github.com/lib/pq v1.10.9 github.com/mark3labs/mcp-go v0.29.0 - github.com/openai/openai-go/v3 v3.15.0 + github.com/openai/openai-go/v3 v3.16.0 github.com/pgvector/pgvector-go v0.3.0 github.com/stretchr/testify v1.10.0 github.com/weaviate/weaviate v1.30.0 diff --git a/go/go.sum b/go/go.sum index 7ab07f334e..655b65c582 100644 --- a/go/go.sum +++ b/go/go.sum @@ -308,8 +308,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8= github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= -github.com/openai/openai-go/v3 v3.15.0 h1:hk99rM7YPz+M99/5B/zOQcVwFRLLMdprVGx1vaZ8XMo= -github.com/openai/openai-go/v3 v3.15.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.16.0 h1:VdqS+GFZgAvEOBcWNyvLVwPlYEIboW5xwiUCcLrVf8c= +github.com/openai/openai-go/v3 v3.16.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= From a137acd71883aad30f59c2c472a9094d0fc3cc94 Mon Sep 17 00:00:00 2001 From: Hugo Aguirre Parra Date: Wed, 14 Jan 2026 18:14:30 +0000 Subject: [PATCH 20/20] add OpenAI provider in README.md --- go/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/go/README.md b/go/README.md index 351847b199..ef302c82b8 100644 --- a/go/README.md +++ b/go/README.md @@ -474,6 +474,7 @@ Genkit provides a unified interface across all major AI providers. Use whichever | **Google AI** | `googlegenai.GoogleAI` | Gemini 2.5 Flash, Gemini 2.5 Pro, and more | | **Vertex AI** | `vertexai.VertexAI` | Gemini models via Google Cloud | | **Anthropic** | `anthropic.Anthropic` | Claude 3.5, Claude 3 Opus, and more | +| **OpenAI** | `openai.OpenAI` | GPT-5, GPT-5-mini, GPT-5-nano, GPT-4o and more | | **Ollama** | `ollama.Ollama` | Llama, Mistral, and other open models | | **OpenAI Compatible** | `compat_oai` | Any OpenAI-compatible API | @@ -484,6 +485,9 @@ g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{})) // Anthropic g := genkit.Init(ctx, genkit.WithPlugins(&anthropic.Anthropic{})) +// OpenAI +g := genkit.Init(ctx, genkit.WithPlugins(&openai.OpenAI{})) + // Ollama (local models) g := genkit.Init(ctx, genkit.WithPlugins(&ollama.Ollama{ ServerAddress: "http://localhost:11434",