Skip to content

Commit c7eb743

Browse files
authored
Merge pull request #1559 from ksylvan/0629-openai-responses-api
OpenAI Plugin Migrates to New Responses API
2 parents 23d678d + de5260a commit c7eb743

File tree

5 files changed

+308
-98
lines changed

5 files changed

+308
-98
lines changed

plugins/ai/openai/chat_completions.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package openai
2+
3+
// This file contains helper methods for the Chat Completions API.
4+
// These methods are used as fallbacks for OpenAI-compatible providers
5+
// that don't support the newer Responses API (e.g., Groq, Mistral, etc.).
6+
7+
import (
8+
"context"
9+
"strings"
10+
11+
"github.com/danielmiessler/fabric/chat"
12+
"github.com/danielmiessler/fabric/common"
13+
openai "github.com/openai/openai-go"
14+
"github.com/openai/openai-go/shared"
15+
)
16+
17+
// sendChatCompletions sends a request using the Chat Completions API
18+
func (o *Client) sendChatCompletions(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions) (ret string, err error) {
19+
req := o.buildChatCompletionParams(msgs, opts)
20+
21+
var resp *openai.ChatCompletion
22+
if resp, err = o.ApiClient.Chat.Completions.New(ctx, req); err != nil {
23+
return
24+
}
25+
if len(resp.Choices) > 0 {
26+
ret = resp.Choices[0].Message.Content
27+
}
28+
return
29+
}
30+
31+
// sendStreamChatCompletions sends a streaming request using the Chat Completions API
32+
func (o *Client) sendStreamChatCompletions(
33+
msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions, channel chan string,
34+
) (err error) {
35+
defer close(channel)
36+
37+
req := o.buildChatCompletionParams(msgs, opts)
38+
stream := o.ApiClient.Chat.Completions.NewStreaming(context.Background(), req)
39+
for stream.Next() {
40+
chunk := stream.Current()
41+
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
42+
channel <- chunk.Choices[0].Delta.Content
43+
}
44+
}
45+
if stream.Err() == nil {
46+
channel <- "\n"
47+
}
48+
return stream.Err()
49+
}
50+
51+
// buildChatCompletionParams builds parameters for the Chat Completions API
52+
func (o *Client) buildChatCompletionParams(
53+
inputMsgs []*chat.ChatCompletionMessage, opts *common.ChatOptions,
54+
) (ret openai.ChatCompletionNewParams) {
55+
56+
messages := make([]openai.ChatCompletionMessageParamUnion, len(inputMsgs))
57+
for i, msgPtr := range inputMsgs {
58+
msg := *msgPtr
59+
if strings.Contains(opts.Model, "deepseek") && len(inputMsgs) == 1 && msg.Role == chat.ChatMessageRoleSystem {
60+
msg.Role = chat.ChatMessageRoleUser
61+
}
62+
messages[i] = o.convertChatMessage(msg)
63+
}
64+
65+
ret = openai.ChatCompletionNewParams{
66+
Model: shared.ChatModel(opts.Model),
67+
Messages: messages,
68+
}
69+
70+
if !opts.Raw {
71+
ret.Temperature = openai.Float(opts.Temperature)
72+
ret.TopP = openai.Float(opts.TopP)
73+
if opts.MaxTokens != 0 {
74+
ret.MaxTokens = openai.Int(int64(opts.MaxTokens))
75+
}
76+
if opts.PresencePenalty != 0 {
77+
ret.PresencePenalty = openai.Float(opts.PresencePenalty)
78+
}
79+
if opts.FrequencyPenalty != 0 {
80+
ret.FrequencyPenalty = openai.Float(opts.FrequencyPenalty)
81+
}
82+
if opts.Seed != 0 {
83+
ret.Seed = openai.Int(int64(opts.Seed))
84+
}
85+
}
86+
return
87+
}
88+
89+
// convertChatMessage converts fabric chat message to OpenAI chat completion message
90+
func (o *Client) convertChatMessage(msg chat.ChatCompletionMessage) openai.ChatCompletionMessageParamUnion {
91+
result := convertMessageCommon(msg)
92+
93+
switch result.Role {
94+
case chat.ChatMessageRoleSystem:
95+
return openai.SystemMessage(result.Content)
96+
case chat.ChatMessageRoleUser:
97+
// Handle multi-content messages (text + images)
98+
if result.HasMultiContent {
99+
var parts []openai.ChatCompletionContentPartUnionParam
100+
for _, p := range result.MultiContent {
101+
switch p.Type {
102+
case chat.ChatMessagePartTypeText:
103+
parts = append(parts, openai.TextContentPart(p.Text))
104+
case chat.ChatMessagePartTypeImageURL:
105+
parts = append(parts, openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{URL: p.ImageURL.URL}))
106+
}
107+
}
108+
return openai.UserMessage(parts)
109+
}
110+
return openai.UserMessage(result.Content)
111+
case chat.ChatMessageRoleAssistant:
112+
return openai.AssistantMessage(result.Content)
113+
default:
114+
return openai.UserMessage(result.Content)
115+
}
116+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package openai
2+
3+
import "github.com/danielmiessler/fabric/chat"
4+
5+
// MessageConversionResult holds the common conversion result
6+
type MessageConversionResult struct {
7+
Role string
8+
Content string
9+
MultiContent []chat.ChatMessagePart
10+
HasMultiContent bool
11+
}
12+
13+
// convertMessageCommon extracts common conversion logic
14+
func convertMessageCommon(msg chat.ChatCompletionMessage) MessageConversionResult {
15+
return MessageConversionResult{
16+
Role: msg.Role,
17+
Content: msg.Content,
18+
MultiContent: msg.MultiContent,
19+
HasMultiContent: len(msg.MultiContent) > 0,
20+
}
21+
}

plugins/ai/openai/openai.go

Lines changed: 116 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package openai
22

33
import (
44
"context"
5-
"log/slog"
65
"slices"
76
"strings"
87

@@ -12,10 +11,13 @@ import (
1211
openai "github.com/openai/openai-go"
1312
"github.com/openai/openai-go/option"
1413
"github.com/openai/openai-go/packages/pagination"
14+
"github.com/openai/openai-go/responses"
15+
"github.com/openai/openai-go/shared"
16+
"github.com/openai/openai-go/shared/constant"
1517
)
1618

1719
func NewClient() (ret *Client) {
18-
return NewClientCompatible("OpenAI", "https://api.openai.com/v1", nil)
20+
return NewClientCompatibleWithResponses("OpenAI", "https://api.openai.com/v1", true, nil)
1921
}
2022

2123
func NewClientCompatible(vendorName string, defaultBaseUrl string, configureCustom func() error) (ret *Client) {
@@ -28,6 +30,17 @@ func NewClientCompatible(vendorName string, defaultBaseUrl string, configureCust
2830
return
2931
}
3032

33+
func NewClientCompatibleWithResponses(vendorName string, defaultBaseUrl string, implementsResponses bool, configureCustom func() error) (ret *Client) {
34+
ret = NewClientCompatibleNoSetupQuestions(vendorName, configureCustom)
35+
36+
ret.ApiKey = ret.AddSetupQuestion("API Key", true)
37+
ret.ApiBaseURL = ret.AddSetupQuestion("API Base URL", false)
38+
ret.ApiBaseURL.Value = defaultBaseUrl
39+
ret.ImplementsResponses = implementsResponses
40+
41+
return
42+
}
43+
3144
func NewClientCompatibleNoSetupQuestions(vendorName string, configureCustom func() error) (ret *Client) {
3245
ret = &Client{}
3346

@@ -46,9 +59,10 @@ func NewClientCompatibleNoSetupQuestions(vendorName string, configureCustom func
4659

4760
type Client struct {
4861
*plugins.PluginBase
49-
ApiKey *plugins.SetupQuestion
50-
ApiBaseURL *plugins.SetupQuestion
51-
ApiClient *openai.Client
62+
ApiKey *plugins.SetupQuestion
63+
ApiBaseURL *plugins.SetupQuestion
64+
ApiClient *openai.Client
65+
ImplementsResponses bool // Whether this provider supports the Responses API
5266
}
5367

5468
func (o *Client) configure() (ret error) {
@@ -75,35 +89,59 @@ func (o *Client) ListModels() (ret []string, err error) {
7589
func (o *Client) SendStream(
7690
msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions, channel chan string,
7791
) (err error) {
78-
req := o.buildChatCompletionParams(msgs, opts)
79-
stream := o.ApiClient.Chat.Completions.NewStreaming(context.Background(), req)
92+
// Use Responses API for OpenAI, Chat Completions API for other providers
93+
if o.supportsResponsesAPI() {
94+
return o.sendStreamResponses(msgs, opts, channel)
95+
}
96+
return o.sendStreamChatCompletions(msgs, opts, channel)
97+
}
98+
99+
func (o *Client) sendStreamResponses(
100+
msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions, channel chan string,
101+
) (err error) {
102+
defer close(channel)
103+
104+
req := o.buildResponseParams(msgs, opts)
105+
stream := o.ApiClient.Responses.NewStreaming(context.Background(), req)
80106
for stream.Next() {
81-
chunk := stream.Current()
82-
if len(chunk.Choices) > 0 {
83-
channel <- chunk.Choices[0].Delta.Content
107+
event := stream.Current()
108+
switch event.Type {
109+
case string(constant.ResponseOutputTextDelta("").Default()):
110+
channel <- event.AsResponseOutputTextDelta().Delta
111+
case string(constant.ResponseOutputTextDone("").Default()):
112+
channel <- event.AsResponseOutputTextDone().Text
84113
}
85114
}
86115
if stream.Err() == nil {
87116
channel <- "\n"
88117
}
89-
close(channel)
90118
return stream.Err()
91119
}
92120

93121
func (o *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions) (ret string, err error) {
94-
req := o.buildChatCompletionParams(msgs, opts)
122+
// Use Responses API for OpenAI, Chat Completions API for other providers
123+
if o.supportsResponsesAPI() {
124+
return o.sendResponses(ctx, msgs, opts)
125+
}
126+
return o.sendChatCompletions(ctx, msgs, opts)
127+
}
128+
129+
func (o *Client) sendResponses(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions) (ret string, err error) {
130+
req := o.buildResponseParams(msgs, opts)
95131

96-
var resp *openai.ChatCompletion
97-
if resp, err = o.ApiClient.Chat.Completions.New(ctx, req); err != nil {
132+
var resp *responses.Response
133+
if resp, err = o.ApiClient.Responses.New(ctx, req); err != nil {
98134
return
99135
}
100-
if len(resp.Choices) > 0 {
101-
ret = resp.Choices[0].Message.Content
102-
slog.Debug("SystemFingerprint: " + resp.SystemFingerprint)
103-
}
136+
ret = o.extractText(resp)
104137
return
105138
}
106139

140+
// supportsResponsesAPI determines if the provider supports the new Responses API
141+
func (o *Client) supportsResponsesAPI() bool {
142+
return o.ImplementsResponses
143+
}
144+
107145
func (o *Client) NeedsRawMode(modelName string) bool {
108146
openaiModelsPrefixes := []string{
109147
"o1",
@@ -115,8 +153,6 @@ func (o *Client) NeedsRawMode(modelName string) bool {
115153
"gpt-4o-mini-search-preview-2025-03-11",
116154
"gpt-4o-search-preview",
117155
"gpt-4o-search-preview-2025-03-11",
118-
"o4-mini-deep-research",
119-
"o4-mini-deep-research-2025-06-26",
120156
}
121157
for _, prefix := range openaiModelsPrefixes {
122158
if strings.HasPrefix(modelName, prefix) {
@@ -126,56 +162,85 @@ func (o *Client) NeedsRawMode(modelName string) bool {
126162
return slices.Contains(openAIModelsNeedingRaw, modelName)
127163
}
128164

129-
func (o *Client) buildChatCompletionParams(
165+
func (o *Client) buildResponseParams(
130166
inputMsgs []*chat.ChatCompletionMessage, opts *common.ChatOptions,
131-
) (ret openai.ChatCompletionNewParams) {
167+
) (ret responses.ResponseNewParams) {
132168

133-
// Create a new slice for messages to be sent, converting from []*Msg to []Msg.
134-
// This also serves as a mutable copy for provider-specific modifications.
135-
messagesForRequest := make([]openai.ChatCompletionMessageParamUnion, len(inputMsgs))
169+
items := make([]responses.ResponseInputItemUnionParam, len(inputMsgs))
136170
for i, msgPtr := range inputMsgs {
137-
msg := *msgPtr // copy
138-
// Provider-specific modification for DeepSeek:
171+
msg := *msgPtr
139172
if strings.Contains(opts.Model, "deepseek") && len(inputMsgs) == 1 && msg.Role == chat.ChatMessageRoleSystem {
140173
msg.Role = chat.ChatMessageRoleUser
141174
}
142-
messagesForRequest[i] = convertMessage(msg)
175+
items[i] = convertMessage(msg)
143176
}
144-
ret = openai.ChatCompletionNewParams{
145-
Model: openai.ChatModel(opts.Model),
146-
Messages: messagesForRequest,
177+
178+
ret = responses.ResponseNewParams{
179+
Model: shared.ResponsesModel(opts.Model),
180+
Input: responses.ResponseNewParamsInputUnion{
181+
OfInputItemList: items,
182+
},
147183
}
184+
148185
if !opts.Raw {
149186
ret.Temperature = openai.Float(opts.Temperature)
150187
ret.TopP = openai.Float(opts.TopP)
151-
ret.PresencePenalty = openai.Float(opts.PresencePenalty)
152-
ret.FrequencyPenalty = openai.Float(opts.FrequencyPenalty)
188+
if opts.MaxTokens != 0 {
189+
ret.MaxOutputTokens = openai.Int(int64(opts.MaxTokens))
190+
}
191+
192+
// Add parameters not officially supported by Responses API as extra fields
193+
extraFields := make(map[string]any)
194+
if opts.PresencePenalty != 0 {
195+
extraFields["presence_penalty"] = opts.PresencePenalty
196+
}
197+
if opts.FrequencyPenalty != 0 {
198+
extraFields["frequency_penalty"] = opts.FrequencyPenalty
199+
}
153200
if opts.Seed != 0 {
154-
ret.Seed = openai.Int(int64(opts.Seed))
201+
extraFields["seed"] = opts.Seed
202+
}
203+
if len(extraFields) > 0 {
204+
ret.SetExtraFields(extraFields)
155205
}
156206
}
157207
return
158208
}
159209

160-
func convertMessage(msg chat.ChatCompletionMessage) openai.ChatCompletionMessageParamUnion {
161-
switch msg.Role {
162-
case chat.ChatMessageRoleSystem:
163-
return openai.SystemMessage(msg.Content)
164-
case chat.ChatMessageRoleUser:
165-
if len(msg.MultiContent) > 0 {
166-
var parts []openai.ChatCompletionContentPartUnionParam
167-
for _, p := range msg.MultiContent {
168-
switch p.Type {
169-
case chat.ChatMessagePartTypeText:
170-
parts = append(parts, openai.TextContentPart(p.Text))
171-
case chat.ChatMessagePartTypeImageURL:
172-
parts = append(parts, openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{URL: p.ImageURL.URL}))
210+
func convertMessage(msg chat.ChatCompletionMessage) responses.ResponseInputItemUnionParam {
211+
result := convertMessageCommon(msg)
212+
role := responses.EasyInputMessageRole(result.Role)
213+
214+
if result.HasMultiContent {
215+
var parts []responses.ResponseInputContentUnionParam
216+
for _, p := range result.MultiContent {
217+
switch p.Type {
218+
case chat.ChatMessagePartTypeText:
219+
parts = append(parts, responses.ResponseInputContentParamOfInputText(p.Text))
220+
case chat.ChatMessagePartTypeImageURL:
221+
part := responses.ResponseInputContentParamOfInputImage(responses.ResponseInputImageDetailAuto)
222+
if part.OfInputImage != nil {
223+
part.OfInputImage.ImageURL = openai.String(p.ImageURL.URL)
224+
}
225+
parts = append(parts, part)
226+
}
227+
}
228+
contentList := responses.ResponseInputMessageContentListParam(parts)
229+
return responses.ResponseInputItemParamOfMessage(contentList, role)
230+
}
231+
return responses.ResponseInputItemParamOfMessage(result.Content, role)
232+
}
233+
234+
func (o *Client) extractText(resp *responses.Response) (ret string) {
235+
for _, item := range resp.Output {
236+
if item.Type == "message" {
237+
for _, c := range item.Content {
238+
if c.Type == "output_text" {
239+
ret += c.AsOutputText().Text
173240
}
174241
}
175-
return openai.UserMessage(parts)
242+
break
176243
}
177-
return openai.UserMessage(msg.Content)
178-
default:
179-
return openai.AssistantMessage(msg.Content)
180244
}
245+
return
181246
}

0 commit comments

Comments
 (0)