Implement OpenAI chat completions in GO (#16177)

### What problem does this PR solve?

Implement OpenAI chat completions in GO

POST /api/v1/openai/<chat_id>/chat/completions

OpenAI chat cli: internal/development.md

### Type of change

- [x] Refactoring
This commit is contained in:
qinling0210
2026-06-18 18:07:27 +08:00
committed by GitHub
parent b53b5bf12c
commit 563d855780
61 changed files with 15327 additions and 2105 deletions

View File

@@ -0,0 +1,351 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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 models
import (
"context"
"encoding/json"
"fmt"
"sync"
"ragflow/internal/tokenizer"
)
const (
defaultMaxRetries = 3
defaultMaxRounds = 5
)
// ChatWithTools runs the non-streaming tool-calling loop.
func (cm *ChatModel) ChatWithTools(ctx context.Context, system string, history []Message, chatCfg *ChatConfig) (string, int, error) {
tc := cm.ToolConfig
if tc == nil {
return "", 0, fmt.Errorf("ChatWithTools called without bound tools")
}
var toolsList interface{}
if err := json.Unmarshal([]byte(tc.Tools), &toolsList); err != nil {
return "", 0, fmt.Errorf("failed to parse tools JSON: %w", err)
}
maxRounds := tc.MaxRounds
if maxRounds <= 0 {
maxRounds = defaultMaxRounds
}
maxRetries := tc.MaxRetries
if maxRetries <= 0 {
maxRetries = defaultMaxRetries
}
if system != "" && len(history) > 0 && history[0].Role != "system" {
history = append([]Message{{Role: "system", Content: system}}, history...)
}
baseHistory := make([]Message, len(history))
copy(baseHistory, history)
for attempt := 0; attempt < maxRetries; attempt++ {
select {
case <-ctx.Done():
return "", 0, ctx.Err()
default:
}
h := make([]Message, len(baseHistory))
copy(h, baseHistory)
answer, tokens, err := runToolLoop(ctx, cm, h, toolsList, chatCfg, maxRounds)
if err == nil {
return answer, tokens, nil
}
}
return "", 0, fmt.Errorf("ChatWithTools failed after %d retries", maxRetries)
}
func runToolLoop(ctx context.Context, cm *ChatModel, history []Message, toolsList interface{}, chatCfg *ChatConfig, maxRounds int) (string, int, error) {
var totalTokens int
for round := 0; round <= maxRounds; round++ {
select {
case <-ctx.Done():
return "", totalTokens, ctx.Err()
default:
}
cfg := *chatCfg
cfg.Tools = toolsList
tcChoice := "auto"
cfg.ToolChoice = &tcChoice
resp, err := cm.ModelDriver.ChatWithMessages(*cm.ModelName, history, cm.APIConfig, &cfg)
if err != nil {
return "", totalTokens, fmt.Errorf("round %d: %w", round, err)
}
if resp == nil {
return "", totalTokens, fmt.Errorf("round %d: nil response", round)
}
if len(resp.ToolCalls) == 0 {
answer := ""
if resp.Answer != nil {
answer = *resp.Answer
}
if resp.ReasonContent != nil && *resp.ReasonContent != "" {
answer = "<think>" + *resp.ReasonContent + "</think>" + answer
}
totalTokens += tokenizer.NumTokensFromString(answer)
return answer, totalTokens, nil
}
history = appendToolResults(history, resp.ToolCalls, cm.ToolConfig.ToolCallSession)
}
// Exceeded max rounds
history = append(history, Message{
Role: "user",
Content: fmt.Sprintf("Exceed max rounds: %d", maxRounds),
})
cfg := *chatCfg
resp, err := cm.ModelDriver.ChatWithMessages(*cm.ModelName, history, cm.APIConfig, &cfg)
if err != nil {
return "", totalTokens, fmt.Errorf("final call: %w", err)
}
if resp == nil || resp.Answer == nil {
return "", totalTokens, fmt.Errorf("final call: no answer")
}
totalTokens += tokenizer.NumTokensFromString(*resp.Answer)
return *resp.Answer, totalTokens, nil
}
// ChatStreamlyWithTools runs the streaming tool-calling loop.
func (cm *ChatModel) ChatStreamlyWithTools(ctx context.Context, system string, history []Message, chatCfg *ChatConfig, sender func(*string, *string) error) (int, error) {
tc := cm.ToolConfig
if tc == nil {
return 0, fmt.Errorf("ChatStreamlyWithTools called without bound tools")
}
var toolsList interface{}
if err := json.Unmarshal([]byte(tc.Tools), &toolsList); err != nil {
return 0, fmt.Errorf("failed to parse tools JSON: %w", err)
}
maxRounds := tc.MaxRounds
if maxRounds <= 0 {
maxRounds = defaultMaxRounds
}
maxRetries := tc.MaxRetries
if maxRetries <= 0 {
maxRetries = defaultMaxRetries
}
if system != "" && len(history) > 0 && history[0].Role != "system" {
history = append([]Message{{Role: "system", Content: system}}, history...)
}
baseHistory := make([]Message, len(history))
copy(baseHistory, history)
for attempt := 0; attempt < maxRetries; attempt++ {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
h := make([]Message, len(baseHistory))
copy(h, baseHistory)
totalTokens, err := runStreamToolLoop(ctx, cm, h, toolsList, chatCfg, maxRounds, sender)
if err == nil {
return totalTokens, nil
}
}
return 0, fmt.Errorf("ChatStreamlyWithTools failed after %d retries", maxRetries)
}
func runStreamToolLoop(ctx context.Context, cm *ChatModel, history []Message, toolsList interface{}, chatCfg *ChatConfig, maxRounds int, sender func(*string, *string) error) (int, error) {
var totalTokens int
for round := 0; round <= maxRounds; round++ {
select {
case <-ctx.Done():
return totalTokens, ctx.Err()
default:
}
cfg := *chatCfg
cfg.Tools = toolsList
tcChoice := "auto"
cfg.ToolChoice = &tcChoice
cfg.Stream = boolPtr(true)
var tcs []map[string]interface{}
cfg.ToolCallsResult = &tcs
reasoningStarted := false
var answer string
var pendingThinkClose bool
err := cm.ModelDriver.ChatStreamlyWithSender(*cm.ModelName, history, cm.APIConfig, &cfg, func(delta *string, reason *string) error {
if reason != nil && *reason != "" {
if !reasoningStarted {
reasoningStarted = true
thinkOpen := "<think>"
if e := sender(&thinkOpen, nil); e != nil {
return e
}
}
pendingThinkClose = true
return sender(reason, nil)
}
// Reasoning ended, close the think block if open
if pendingThinkClose {
pendingThinkClose = false
thinkClose := "</think>"
if e := sender(&thinkClose, nil); e != nil {
return e
}
}
if delta != nil && *delta != "" {
if *delta == "[DONE]" {
return nil
}
totalTokens += tokenizer.NumTokensFromString(*delta)
answer += *delta
if e := sender(delta, nil); e != nil {
return e
}
}
return nil
})
// Close any unclosed think block after stream completes
if pendingThinkClose {
pendingThinkClose = false
thinkClose := "</think>"
if e := sender(&thinkClose, nil); e != nil {
return totalTokens, e
}
}
if err != nil {
return totalTokens, fmt.Errorf("round %d: %w", round, err)
}
var toolCalls []map[string]interface{}
if cfg.ToolCallsResult != nil {
toolCalls = *cfg.ToolCallsResult
}
if answer != "" && len(toolCalls) == 0 {
return totalTokens, nil
}
if len(toolCalls) == 0 {
return totalTokens, fmt.Errorf("round %d: no content and no tool_calls", round)
}
history = appendToolResults(history, toolCalls, cm.ToolConfig.ToolCallSession)
}
// Exceeded max rounds
history = append(history, Message{
Role: "user",
Content: fmt.Sprintf("Exceed max rounds: %d", maxRounds),
})
cfg := *chatCfg
cfg.Stream = boolPtr(true)
return totalTokens, cm.ModelDriver.ChatStreamlyWithSender(*cm.ModelName, history, cm.APIConfig, &cfg, sender)
}
// appendToolResults executes tool calls concurrently, appends the assistant
// message with tool_calls and individual tool result messages to history.
func appendToolResults(history []Message, toolCalls []map[string]interface{}, session ToolCallSession) []Message {
if session == nil {
history = append(history, Message{
Role: "assistant",
Content: nil,
ToolCalls: toolCalls,
})
for _, tc := range toolCalls {
tcID, _ := tc["id"].(string)
history = append(history, Message{
Role: "tool",
Content: "Error: no tool session configured",
ToolCallID: tcID,
})
}
return history
}
var mu sync.Mutex
var wg sync.WaitGroup
type toolResult struct {
index int
tcID string
content string
}
results := make([]toolResult, len(toolCalls))
for i, tc := range toolCalls {
wg.Add(1)
go func(idx int, tcMap map[string]interface{}) {
defer wg.Done()
var result toolResult
result.index = idx
fn, ok := tcMap["function"].(map[string]interface{})
if !ok {
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
name, _ := fn["name"].(string)
argsStr, _ := fn["arguments"].(string)
result.tcID, _ = tcMap["id"].(string)
var args map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &args); err != nil {
args = map[string]interface{}{"raw_arguments": argsStr}
}
res, err := session.ToolCall(name, args)
if err != nil {
result.content = fmt.Sprintf("Error: %s", err.Error())
} else {
result.content = res
}
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, tc)
}
wg.Wait()
history = append(history, Message{
Role: "assistant",
Content: nil,
ToolCalls: toolCalls,
})
for _, r := range results {
history = append(history, Message{
Role: "tool",
Content: r.content,
ToolCallID: r.tcID,
})
}
return history
}
func boolPtr(b bool) *bool {
return &b
}

View File

@@ -73,21 +73,27 @@ func (o *OpenAIModel) ChatWithMessages(modelName string, messages []Message, api
baseURL = strings.TrimSuffix(baseURL, "/")
url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Chat)
// Convert messages to the format expected by the API
// Convert messages to API format (supports multimodal content)
apiMessages := make([]map[string]interface{}, len(messages))
for i, msg := range messages {
apiMessages[i] = map[string]interface{}{
apiMsg := map[string]interface{}{
"role": msg.Role,
"content": msg.Content,
}
if msg.ToolCallID != "" {
apiMsg["tool_call_id"] = msg.ToolCallID
}
if len(msg.ToolCalls) > 0 {
apiMsg["tool_calls"] = msg.ToolCalls
}
apiMessages[i] = apiMsg
}
// Build request body
reqBody := map[string]interface{}{
"model": modelName,
"messages": apiMessages,
"stream": false,
"temperature": 1,
"model": modelName,
"messages": apiMessages,
"stream": false,
}
if chatModelConfig != nil {
@@ -106,6 +112,21 @@ func (o *OpenAIModel) ChatWithMessages(modelName string, messages []Message, api
if chatModelConfig.Stop != nil {
reqBody["stop"] = *chatModelConfig.Stop
}
if chatModelConfig.Tools != nil {
reqBody["tools"] = chatModelConfig.Tools
tc := "auto"
if chatModelConfig.ToolChoice != nil {
tc = *chatModelConfig.ToolChoice
}
reqBody["tool_choice"] = tc
}
}
// Qwen3 family: disable thinking by default (matches Python's
// _apply_model_family_policies in rag/llm/chat_model.py:119-121).
if strings.Contains(strings.ToLower(modelName), "qwen3") && (chatModelConfig == nil || chatModelConfig.Thinking == nil) {
reqBody["enable_thinking"] = false
}
jsonData, err := json.Marshal(reqBody)
@@ -160,9 +181,9 @@ func (o *OpenAIModel) ChatWithMessages(modelName string, messages []Message, api
return nil, fmt.Errorf("invalid message format")
}
content, ok := messageMap["content"].(string)
if !ok {
return nil, fmt.Errorf("invalid content format")
var content string
if c, ok := messageMap["content"].(string); ok {
content = c
}
// OpenAI reasoning models (o-series and similar) return reasoning text in
@@ -175,9 +196,19 @@ func (o *OpenAIModel) ChatWithMessages(modelName string, messages []Message, api
}
}
var toolCalls []map[string]interface{}
if tcs, ok := messageMap["tool_calls"].([]interface{}); ok {
for _, tc := range tcs {
if tcMap, ok := tc.(map[string]interface{}); ok {
toolCalls = append(toolCalls, tcMap)
}
}
}
chatResponse := &ChatResponse{
Answer: &content,
ReasonContent: &reasonContent,
ToolCalls: toolCalls,
}
return chatResponse, nil
@@ -200,13 +231,20 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag
baseURL = strings.TrimSuffix(baseURL, "/")
url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Chat)
// Convert messages to API format (supports multimodal content)
// Convert messages to API format (supports multimodal content and tool messages)
apiMessages := make([]map[string]interface{}, len(messages))
for i, msg := range messages {
apiMessages[i] = map[string]interface{}{
apiMsg := map[string]interface{}{
"role": msg.Role,
"content": msg.Content,
}
if msg.ToolCallID != "" {
apiMsg["tool_call_id"] = msg.ToolCallID
}
if len(msg.ToolCalls) > 0 {
apiMsg["tool_calls"] = msg.ToolCalls
}
apiMessages[i] = apiMsg
}
// Build request body with streaming on by default
@@ -236,6 +274,20 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag
if chatModelConfig.Stop != nil {
reqBody["stop"] = *chatModelConfig.Stop
}
if chatModelConfig.Tools != nil {
reqBody["tools"] = chatModelConfig.Tools
tc := "auto"
if chatModelConfig.ToolChoice != nil {
tc = *chatModelConfig.ToolChoice
}
reqBody["tool_choice"] = tc
}
}
// Qwen3 family: disable thinking by default.
if strings.Contains(strings.ToLower(modelName), "qwen3") && (chatModelConfig == nil || chatModelConfig.Thinking == nil) {
reqBody["enable_thinking"] = false
}
jsonData, err := json.Marshal(reqBody)
@@ -263,20 +315,75 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag
}
sawTerminal := false
done, err := ParseSSEStream[map[string]interface{}](resp.Body, func(event map[string]interface{}) error {
accumulatedToolCalls := make(map[int]map[string]interface{})
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
// SSE data line starts with "data:"
if !strings.HasPrefix(line, "data:") {
continue
}
// Extract JSON after "data:"
data := strings.TrimSpace(line[5:])
// [DONE] marks the end of the stream
if data == "[DONE]" {
sawTerminal = true
break
}
// Parse the JSON event
var event map[string]interface{}
if err = json.Unmarshal([]byte(data), &event); err != nil {
continue
}
choices, ok := event["choices"].([]interface{})
if !ok || len(choices) == 0 {
return nil
continue
}
firstChoice, ok := choices[0].(map[string]interface{})
if !ok {
return nil
continue
}
delta, ok := firstChoice["delta"].(map[string]interface{})
if !ok {
return nil
continue
}
// Accumulate streaming tool_call deltas (mirrors Python's
// async_chat_streamly_with_tools in rag/llm/chat_model.py:500-509).
if tcs, ok := delta["tool_calls"].([]interface{}); ok {
for _, tc := range tcs {
if tcMap, ok := tc.(map[string]interface{}); ok {
idxF, ok := tcMap["index"].(float64)
if !ok {
continue
}
idx := int(idxF)
existing, hasExisting := accumulatedToolCalls[idx]
if hasExisting {
if fn, ok := tcMap["function"].(map[string]interface{}); ok {
if args, ok := fn["arguments"].(string); ok {
if ef, ok := existing["function"].(map[string]interface{}); ok {
if ea, ok := ef["arguments"].(string); ok {
ef["arguments"] = ea + args
} else {
ef["arguments"] = args
}
}
}
}
} else {
accumulatedToolCalls[idx] = cloneMap(tcMap)
}
}
}
continue // tool_call deltas don't carry content
}
reasoningContent, ok := delta["reasoning_content"].(string)
@@ -297,15 +404,23 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag
if ok && finishReason != "" {
sawTerminal = true
}
return nil
})
if err != nil {
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to scan response body: %w", err)
}
if !done && !sawTerminal {
if !sawTerminal {
return fmt.Errorf("openai: stream ended before [DONE] or finish_reason")
}
// Populate ToolCallsResult with accumulated streaming tool_calls.
if len(accumulatedToolCalls) > 0 && chatModelConfig != nil {
tcs := make([]map[string]interface{}, 0, len(accumulatedToolCalls))
for _, tc := range accumulatedToolCalls {
tcs = append(tcs, tc)
}
chatModelConfig.ToolCallsResult = &tcs
}
// Send the [DONE] marker for OpenAI compatibility
endOfStream := "[DONE]"
if err := sender(&endOfStream, nil); err != nil {
@@ -907,3 +1022,11 @@ func (o *OpenAIModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error)
func (o *OpenAIModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) {
return nil, fmt.Errorf("%s, no such method", o.Name())
}
func cloneMap(m map[string]interface{}) map[string]interface{} {
cp := make(map[string]interface{}, len(m))
for k, v := range m {
cp[k] = v
}
return cp
}

View File

@@ -93,10 +93,9 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message
// Build request body
reqBody := map[string]interface{}{
"model": modelName,
"messages": apiMessages,
"stream": false,
"temperature": 1,
"model": modelName,
"messages": apiMessages,
"stream": false,
}
if chatModelConfig != nil {
@@ -119,18 +118,12 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message
if chatModelConfig.Stop != nil {
reqBody["stop"] = *chatModelConfig.Stop
}
}
if chatModelConfig.Thinking != nil {
if *chatModelConfig.Thinking {
reqBody["thinking"] = map[string]interface{}{
"type": "enabled",
}
} else {
reqBody["thinking"] = map[string]interface{}{
"type": "disabled",
}
}
}
// Qwen3 family: disable thinking by default (matches Python's
// _apply_model_family_policies in rag/llm/chat_model.py:119-121).
if strings.Contains(strings.ToLower(modelName), "qwen3") && (chatModelConfig == nil || chatModelConfig.Thinking == nil) {
reqBody["enable_thinking"] = false
}
jsonData, err := json.Marshal(reqBody)
@@ -243,10 +236,9 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M
// Build request body with streaming enabled
reqBody := map[string]interface{}{
"model": modelName,
"messages": apiMessages,
"stream": true,
"temperature": 1,
"model": modelName,
"messages": apiMessages,
"stream": true,
}
if chatModelConfig != nil {
@@ -273,18 +265,12 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M
if chatModelConfig.Stop != nil {
reqBody["stop"] = *chatModelConfig.Stop
}
}
if chatModelConfig.Thinking != nil {
if *chatModelConfig.Thinking {
reqBody["thinking"] = map[string]interface{}{
"type": "enabled",
}
} else {
reqBody["thinking"] = map[string]interface{}{
"type": "disabled",
}
}
}
// Qwen3 family: disable thinking by default (matches Python's
// _apply_model_family_policies in rag/llm/chat_model.py:119-121).
if strings.Contains(strings.ToLower(modelName), "qwen3") && (chatModelConfig == nil || chatModelConfig.Thinking == nil) {
reqBody["enable_thinking"] = false
}
jsonData, err := json.Marshal(reqBody)

View File

@@ -1,5 +1,7 @@
package models
import "encoding/json"
// Message represents a chat message with role and content
//
// Content is interface{} to support different formats:
@@ -7,8 +9,15 @@ package models
// - []interface{}: multimodal content array where each element is map[string]interface{}
// (e.g., [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {"url": "..."}}])
type Message struct {
Role string `json:"role"`
Content interface{} `json:"content"`
Role string `json:"role"`
Content interface{} `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []map[string]interface{} `json:"tool_calls,omitempty"`
}
// ToolCallSession mirrors Python's common.mcp_tool_call_conn.ToolCallSession protocol.
type ToolCallSession interface {
ToolCall(name string, arguments map[string]interface{}) (string, error)
}
// EmbeddingModel interface for embedding models
@@ -48,8 +57,9 @@ type ModelDriver interface {
}
type ChatResponse struct {
Answer *string `json:"answer"`
ReasonContent *string `json:"reason_content"`
Answer *string `json:"answer"`
ReasonContent *string `json:"reason_content"`
ToolCalls []map[string]interface{} `json:"tool_calls,omitempty"`
}
type EmbeddingData struct {
@@ -130,17 +140,20 @@ type URLSuffix struct {
}
type ChatConfig struct {
Stream *bool
Vision *bool
Thinking *bool
MaxTokens *int
Temperature *float64
TopP *float64
DoSample *bool
Stop *[]string
ModelClass *string
Effort *string
Verbosity *string
Stream *bool
Vision *bool
Thinking *bool
MaxTokens *int
Temperature *float64
TopP *float64
DoSample *bool
Stop *[]string
ModelClass *string
Effort *string
Verbosity *string
Tools interface{} `json:"tools,omitempty"`
ToolChoice *string `json:"tool_choice,omitempty"`
ToolCallsResult *[]map[string]interface{} `json:"-"`
}
type APIConfig struct {
@@ -211,11 +224,20 @@ func (r *RerankModel) Rerank(query string, texts []string, apiConfig *APIConfig,
return r.ModelDriver.Rerank(r.ModelName, query, texts, apiConfig, rerankConfig)
}
// ToolConfig bundles tool-calling configuration for a ChatModel.
type ToolConfig struct {
Tools string // JSON-encoded tools list
MaxRounds int // max tool-calling rounds (default: 5)
MaxRetries int // max retries on failure (default: 3)
ToolCallSession ToolCallSession // session that executes tool calls
}
// ChatModel wraps a ModelDriver with chat-specific configuration
type ChatModel struct {
ModelDriver ModelDriver
ModelName *string
APIConfig *APIConfig
ToolConfig *ToolConfig
}
// NewChatModel creates a new ChatModel
@@ -226,3 +248,26 @@ func NewChatModel(driver ModelDriver, modelName *string, apiConfig *APIConfig) *
APIConfig: apiConfig,
}
}
// BindTools registers tools for the ChatModel to call.
// Mirrors Python's Base.bind_tools() in rag/llm/chat_model.py.
func (cm *ChatModel) BindTools(session ToolCallSession, tools interface{}) {
// Serialize tools to JSON if it's a list/map.
toolsJSON := ""
switch v := tools.(type) {
case string:
toolsJSON = v
case []byte:
toolsJSON = string(v)
default:
if b, err := json.Marshal(tools); err == nil {
toolsJSON = string(b)
}
}
cm.ToolConfig = &ToolConfig{
Tools: toolsJSON,
MaxRounds: defaultMaxRounds,
MaxRetries: defaultMaxRetries,
ToolCallSession: session,
}
}