feat: add chat completions API support for OpenAI-compatible providers

## CHANGES

* Add chat completions API fallback for non-Responses API providers
* Implement `sendChatCompletions` and `sendStreamChatCompletions` methods
* Introduce `buildChatCompletionParams` to construct API request parameters
* Add `ImplementsResponses` flag to track provider API capabilities
* Update provider configurations with Responses API support status
* Enhance `Send` and `SendStream` methods to use appropriate API endpoints
This commit is contained in:
Kayvan Sylvan
2025-06-29 22:52:55 -07:00
parent d8fc6940f0
commit 6179742e79
3 changed files with 202 additions and 26 deletions

View File

@@ -0,0 +1,102 @@
package openai
// This file contains helper methods for the Chat Completions API.
// These methods are used as fallbacks for OpenAI-compatible providers
// that don't support the newer Responses API (e.g., Groq, Mistral, etc.).
import (
"context"
"strings"
"github.com/danielmiessler/fabric/chat"
"github.com/danielmiessler/fabric/common"
openai "github.com/openai/openai-go"
"github.com/openai/openai-go/shared"
)
// sendChatCompletions sends a request using the Chat Completions API
func (o *Client) sendChatCompletions(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions) (ret string, err error) {
req := o.buildChatCompletionParams(msgs, opts)
var resp *openai.ChatCompletion
if resp, err = o.ApiClient.Chat.Completions.New(ctx, req); err != nil {
return
}
if len(resp.Choices) > 0 {
ret = resp.Choices[0].Message.Content
}
return
}
// sendStreamChatCompletions sends a streaming request using the Chat Completions API
func (o *Client) sendStreamChatCompletions(
msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions, channel chan string,
) (err error) {
req := o.buildChatCompletionParams(msgs, opts)
stream := o.ApiClient.Chat.Completions.NewStreaming(context.Background(), req)
for stream.Next() {
chunk := stream.Current()
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
channel <- chunk.Choices[0].Delta.Content
}
}
if stream.Err() == nil {
channel <- "\n"
}
close(channel)
return stream.Err()
}
// buildChatCompletionParams builds parameters for the Chat Completions API
func (o *Client) buildChatCompletionParams(
inputMsgs []*chat.ChatCompletionMessage, opts *common.ChatOptions,
) (ret openai.ChatCompletionNewParams) {
messages := make([]openai.ChatCompletionMessageParamUnion, len(inputMsgs))
for i, msgPtr := range inputMsgs {
msg := *msgPtr
if strings.Contains(opts.Model, "deepseek") && len(inputMsgs) == 1 && msg.Role == chat.ChatMessageRoleSystem {
msg.Role = chat.ChatMessageRoleUser
}
messages[i] = o.convertChatMessage(msg)
}
ret = openai.ChatCompletionNewParams{
Model: shared.ChatModel(opts.Model),
Messages: messages,
}
if !opts.Raw {
ret.Temperature = openai.Float(opts.Temperature)
ret.TopP = openai.Float(opts.TopP)
if opts.MaxTokens != 0 {
ret.MaxTokens = openai.Int(int64(opts.MaxTokens))
}
if opts.PresencePenalty != 0 {
ret.PresencePenalty = openai.Float(opts.PresencePenalty)
}
if opts.FrequencyPenalty != 0 {
ret.FrequencyPenalty = openai.Float(opts.FrequencyPenalty)
}
if opts.Seed != 0 {
ret.Seed = openai.Int(int64(opts.Seed))
}
}
return
}
// convertChatMessage converts fabric chat message to OpenAI chat completion message
func (o *Client) convertChatMessage(msg chat.ChatCompletionMessage) openai.ChatCompletionMessageParamUnion {
// For now, simplify to text-only messages to get the basic functionality working
// Multi-content support can be added later if needed
switch msg.Role {
case chat.ChatMessageRoleSystem:
return openai.SystemMessage(msg.Content)
case chat.ChatMessageRoleUser:
return openai.UserMessage(msg.Content)
case chat.ChatMessageRoleAssistant:
return openai.AssistantMessage(msg.Content)
default:
return openai.UserMessage(msg.Content)
}
}

View File

@@ -30,6 +30,17 @@ func NewClientCompatible(vendorName string, defaultBaseUrl string, configureCust
return
}
func NewClientCompatibleWithResponses(vendorName string, defaultBaseUrl string, implementsResponses bool, configureCustom func() error) (ret *Client) {
ret = NewClientCompatibleNoSetupQuestions(vendorName, configureCustom)
ret.ApiKey = ret.AddSetupQuestion("API Key", true)
ret.ApiBaseURL = ret.AddSetupQuestion("API Base URL", false)
ret.ApiBaseURL.Value = defaultBaseUrl
ret.ImplementsResponses = implementsResponses
return
}
func NewClientCompatibleNoSetupQuestions(vendorName string, configureCustom func() error) (ret *Client) {
ret = &Client{}
@@ -48,9 +59,10 @@ func NewClientCompatibleNoSetupQuestions(vendorName string, configureCustom func
type Client struct {
*plugins.PluginBase
ApiKey *plugins.SetupQuestion
ApiBaseURL *plugins.SetupQuestion
ApiClient *openai.Client
ApiKey *plugins.SetupQuestion
ApiBaseURL *plugins.SetupQuestion
ApiClient *openai.Client
ImplementsResponses bool // Whether this provider supports the Responses API
}
func (o *Client) configure() (ret error) {
@@ -76,6 +88,16 @@ func (o *Client) ListModels() (ret []string, err error) {
func (o *Client) SendStream(
msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions, channel chan string,
) (err error) {
// Use Responses API for OpenAI, Chat Completions API for other providers
if o.supportsResponsesAPI() {
return o.sendStreamResponses(msgs, opts, channel)
}
return o.sendStreamChatCompletions(msgs, opts, channel)
}
func (o *Client) sendStreamResponses(
msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions, channel chan string,
) (err error) {
req := o.buildResponseParams(msgs, opts)
stream := o.ApiClient.Responses.NewStreaming(context.Background(), req)
@@ -96,6 +118,14 @@ func (o *Client) SendStream(
}
func (o *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions) (ret string, err error) {
// Use Responses API for OpenAI, Chat Completions API for other providers
if o.supportsResponsesAPI() {
return o.sendResponses(ctx, msgs, opts)
}
return o.sendChatCompletions(ctx, msgs, opts)
}
func (o *Client) sendResponses(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *common.ChatOptions) (ret string, err error) {
req := o.buildResponseParams(msgs, opts)
var resp *responses.Response
@@ -106,6 +136,19 @@ func (o *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
return
}
// supportsResponsesAPI determines if the provider supports the new Responses API
func (o *Client) supportsResponsesAPI() bool {
// For the main OpenAI client, check the base URL (backward compatibility)
if o.ApiBaseURL != nil {
baseURL := o.ApiBaseURL.Value
if baseURL == "" || baseURL == "https://api.openai.com/v1" {
return true
}
}
// For OpenAI-compatible providers, use the explicit flag
return o.ImplementsResponses
}
func (o *Client) NeedsRawMode(modelName string) bool {
openaiModelsPrefixes := []string{
"o1",
@@ -154,6 +197,21 @@ func (o *Client) buildResponseParams(
if opts.MaxTokens != 0 {
ret.MaxOutputTokens = openai.Int(int64(opts.MaxTokens))
}
// Add parameters not officially supported by Responses API as extra fields
extraFields := make(map[string]any)
if opts.PresencePenalty != 0 {
extraFields["presence_penalty"] = opts.PresencePenalty
}
if opts.FrequencyPenalty != 0 {
extraFields["frequency_penalty"] = opts.FrequencyPenalty
}
if opts.Seed != 0 {
extraFields["seed"] = opts.Seed
}
if len(extraFields) > 0 {
ret.SetExtraFields(extraFields)
}
}
return
}

View File

@@ -9,8 +9,9 @@ import (
// ProviderConfig defines the configuration for an OpenAI-compatible API provider
type ProviderConfig struct {
Name string
BaseURL string
Name string
BaseURL string
ImplementsResponses bool // Whether the provider supports OpenAI's new Responses API
}
// Client is the common structure for all OpenAI-compatible providers
@@ -21,51 +22,66 @@ type Client struct {
// NewClient creates a new OpenAI-compatible client for the specified provider
func NewClient(providerConfig ProviderConfig) *Client {
client := &Client{}
client.Client = openai.NewClientCompatible(providerConfig.Name, providerConfig.BaseURL, nil)
client.Client = openai.NewClientCompatibleWithResponses(
providerConfig.Name,
providerConfig.BaseURL,
providerConfig.ImplementsResponses,
nil,
)
return client
}
// ProviderMap is a map of provider name to ProviderConfig for O(1) lookup
var ProviderMap = map[string]ProviderConfig{
"AIML": {
Name: "AIML",
BaseURL: "https://api.aimlapi.com/v1",
Name: "AIML",
BaseURL: "https://api.aimlapi.com/v1",
ImplementsResponses: false,
},
"Cerebras": {
Name: "Cerebras",
BaseURL: "https://api.cerebras.ai/v1",
Name: "Cerebras",
BaseURL: "https://api.cerebras.ai/v1",
ImplementsResponses: false,
},
"DeepSeek": {
Name: "DeepSeek",
BaseURL: "https://api.deepseek.com",
Name: "DeepSeek",
BaseURL: "https://api.deepseek.com",
ImplementsResponses: false,
},
"GrokAI": {
Name: "GrokAI",
BaseURL: "https://api.x.ai/v1",
Name: "GrokAI",
BaseURL: "https://api.x.ai/v1",
ImplementsResponses: false,
},
"Groq": {
Name: "Groq",
BaseURL: "https://api.groq.com/openai/v1",
Name: "Groq",
BaseURL: "https://api.groq.com/openai/v1",
ImplementsResponses: false,
},
"Langdock": {
Name: "Langdock",
BaseURL: "https://api.langdock.com/openai/{{REGION=us}}/v1",
Name: "Langdock",
BaseURL: "https://api.langdock.com/openai/{{REGION=us}}/v1",
ImplementsResponses: false,
},
"LiteLLM": {
Name: "LiteLLM",
BaseURL: "http://localhost:4000",
Name: "LiteLLM",
BaseURL: "http://localhost:4000",
ImplementsResponses: false,
},
"Mistral": {
Name: "Mistral",
BaseURL: "https://api.mistral.ai/v1",
Name: "Mistral",
BaseURL: "https://api.mistral.ai/v1",
ImplementsResponses: false,
},
"OpenRouter": {
Name: "OpenRouter",
BaseURL: "https://openrouter.ai/api/v1",
Name: "OpenRouter",
BaseURL: "https://openrouter.ai/api/v1",
ImplementsResponses: false,
},
"SiliconCloud": {
Name: "SiliconCloud",
BaseURL: "https://api.siliconflow.cn/v1",
Name: "SiliconCloud",
BaseURL: "https://api.siliconflow.cn/v1",
ImplementsResponses: false,
},
}