Implement chat completions in go (#16491)

### Summary

POST   /api/v1/chat/completions
This commit is contained in:
qinling0210
2026-07-01 15:52:52 +08:00
committed by GitHub
parent b8e960e6c8
commit 7862f69f39
19 changed files with 1917 additions and 2362 deletions

View File

@@ -834,6 +834,8 @@ Commands (User Mode):
CHAT 'provider/instance/model' 'message'; - Chat with specified model
OPENAI_CHAT 'chat_id' 'message' [options] ; - OpenAI-compatible chat
(run openai_chat -h for detailed options)
CHAT COMPLETIONS 'question' [options] ; - Chat completions via /api/v1/chat/completions
(run chat completions -h for detailed options)
Filesystem Commands (no quotes):
ls [path] - List resources
@@ -1041,3 +1043,50 @@ Examples:
`
fmt.Println(help)
}
// printChatCompletionsHelp prints help for the CHAT COMPLETIONS command.
func printChatCompletionsHelp() {
help := `CHAT COMPLETIONS — hit POST /api/v1/chat/completions
Syntax:
CHAT COMPLETIONS 'question'
chat_id '...'
[session "..."] [llm "..."]
[system "..."] [history "..."] [history_delimiter "<char>"]
[temperature <float>] [max_tokens <int>] [stream <bool>]
[top_p <float>] [frequency_penalty <float>] [presence_penalty <float>]
[pass_all_history <bool>] [legacy <bool>] ;
Required positional:
'question' the user question
Named options (any order; all optional with defaults):
chat_id '...' the dialog id (optional)
session '...' existing session/conversation id
llm '...' override the dialog's LLM
system '...' override the system prompt
history '...' prior turns: user:...;assistant:...;user:...
history_delimiter '...' turn separator for history (default ';')
temperature <float> 0..2 (default 0)
max_tokens <int> (default 0 = server/model default)
stream <bool> true|false (default false)
top_p <float> 0..1
frequency_penalty <float> -2..2
presence_penalty <float> -2..2
pass_all_history <bool> pass all history messages
legacy <bool> use legacy SSE format
Defaults:
stream false
temperature 0
history_delimiter ';'
Examples:
CHAT COMPLETIONS 'Hello, how are you?' chat_id 'cid';
CHAT COMPLETIONS 'Explain quantum computing' chat_id 'cid' stream true;
CHAT COMPLETIONS 'Next question' chat_id 'cid' session 'sess-abc123';
CHAT COMPLETIONS 'What about X?' chat_id 'cid' system 'You are a helpful assistant.' history 'user:Tell me about Y;assistant:Y is...';
CHAT COMPLETIONS 'Summarize' chat_id 'cid' llm 'Qwen/Qwen3-8B@ling@SILICONFLOW' temperature 0.7 max_tokens 512;
`
fmt.Println(help)
}

View File

@@ -419,6 +419,11 @@ func (c *CLI) ExecuteUserCommand(cmd *Command) (ResponseIf, error) {
return c.EmbedUserTextCommand(cmd)
case "api_rarank_user_document":
return c.APIRerankUserDocumentCommand(cmd)
case "chat completions":
return c.ChatCompletions(cmd)
case "chat completions help":
printChatCompletionsHelp()
return nil, nil
case "tts_user_command":
return c.APITTSUserCommand(cmd)
case "asr_user_command":

View File

@@ -980,9 +980,11 @@ func getChunkID(c map[string]interface{}) string {
}
func chunkContent(c map[string]interface{}) string {
if v, ok := c["content"]; ok {
s := fmt.Sprint(v)
return strings.TrimSpace(s)
for _, key := range []string{"content_with_weight", "content"} {
if v, ok := c[key]; ok {
s := fmt.Sprint(v)
return strings.TrimSpace(s)
}
}
return ""
}
@@ -1283,3 +1285,59 @@ func (r *QuotaSummaryResponse) PrintOut() {
PrintTableSimpleByFormatWithOrder(table, section.columns, r.OutputFormat)
}
}
// ChatCompletionsResponse represents the RAGFlow-internal response from
// POST /api/v1/chat/completions (non-OpenAI format).
//
// JSON shape:
//
// {"code":0,"data":{"answer":"...","reference":...},"message":""}
type ChatCompletionsResponse struct {
Code int `json:"code"`
Data *chatCompletionData `json:"data"`
Message string `json:"message"`
Duration float64 `json:"-"`
OutputFormat OutputFormat `json:"-"`
// raw HTTP body for "raw" output.
raw []byte
// streamed skips the "Answer:" line in PrintOut to avoid duplication
// (used by the streaming path which prints chunk-by-chunk).
streamed bool
}
type chatCompletionData struct {
Answer string `json:"answer"`
Reference json.RawMessage `json:"reference,omitempty"`
ID string `json:"id,omitempty"`
SessionID string `json:"session_id,omitempty"`
ChatID string `json:"chat_id,omitempty"`
}
func (r *ChatCompletionsResponse) Type() string { return "chat_completions" }
func (r *ChatCompletionsResponse) TimeCost() float64 { return r.Duration }
func (r *ChatCompletionsResponse) SetOutputFormat(f OutputFormat) { r.OutputFormat = f }
func (r *ChatCompletionsResponse) PrintOut() {
if r.OutputFormat == "raw" && r.raw != nil {
fmt.Println(string(r.raw))
return
}
if r.Code != 0 {
fmt.Println("ERROR")
fmt.Printf("%d, %s\n", r.Code, r.Message)
return
}
if r.Data == nil {
fmt.Println("(no data)")
return
}
if !r.streamed {
if r.Data.Answer != "" {
fmt.Printf("Answer: %s\n", r.Data.Answer)
}
}
if r.Data != nil && len(r.Data.Reference) > 0 {
printReferenceChunks(r.Data.Reference)
}
fmt.Printf("Time: %f\n", r.Duration)
}

View File

@@ -32,7 +32,6 @@ import (
"ragflow/internal/ingestion"
"ragflow/internal/ingestion/parser"
"ragflow/internal/utility"
"regexp"
"strings"
"time"
)
@@ -2128,7 +2127,7 @@ func (c *CLI) APIChatToModelCommand(cmd *Command) (ResponseIf, error) {
effort := cmd.Params["effort"].(string)
verbosity := cmd.Params["verbosity"].(string)
url := "/chat/completions"
url := "/chat/to_model"
payload := map[string]interface{}{
"messages": formattedMessages,
@@ -4133,8 +4132,6 @@ func (c *CLI) streamOpenaiChat(url string, body map[string]interface{}) (Respons
fullContent = strings.TrimLeft(fullContent, "\n\r")
fullReason = strings.TrimLeft(fullReason, "\n\r")
fullContent = stripThinkTags(fullContent)
fullReason = stripThinkTags(fullReason)
return &OpenAIChatResponse{
Duration: resp.Duration,
Reasoning: fullReason,
@@ -4147,8 +4144,187 @@ func (c *CLI) streamOpenaiChat(url string, body map[string]interface{}) (Respons
}, nil
}
// stripThinkTags removes <think>…</think> wrappers from a streamed answer
func stripThinkTags(s string) string {
var thinkTagRE = regexp.MustCompile(`(?s)<think>.*?</think>`)
return thinkTagRE.ReplaceAllString(s, "")
// ChatCompletions dispatches the parsed CHAT COMPLETIONS command to
// POST /api/v1/chat/completions.
func (c *CLI) ChatCompletions(cmd *Command) (ResponseIf, error) {
if c.Config.CLIMode != APIMode {
return nil, fmt.Errorf("CHAT COMPLETIONS is only allowed in USER mode")
}
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
if httpClient.APIKey == nil && httpClient.LoginToken == nil {
return nil, fmt.Errorf("API token not set. Please login first")
}
body, err := buildChatCompletionsRequestBody(cmd)
if err != nil {
return nil, err
}
url := "/chat/completions"
stream, _ := cmd.Params["stream"].(bool)
if stream {
return c.streamChatCompletions(url, body)
}
return c.oneshotChatCompletions(url, body)
}
// buildChatCompletionsRequestBody assembles the JSON payload for
// POST /api/v1/chat/completions.
//
// When system or history is provided, a `messages` array is built;
// otherwise just `question` is sent and the server normalizes it.
func buildChatCompletionsRequestBody(cmd *Command) (map[string]interface{}, error) {
chatID, _ := cmd.Params["chat_id"].(string)
question, _ := cmd.Params["question"].(string)
stream, _ := cmd.Params["stream"].(bool)
body := map[string]interface{}{
"chat_id": chatID,
"stream": stream,
}
// Optional session_id
if v, ok := cmd.Params["session"].(string); ok && v != "" {
body["session_id"] = v
}
// Optional llm_id
if v, ok := cmd.Params["llm"].(string); ok && v != "" {
body["llm_id"] = v
}
// Build messages from system + history when provided; otherwise send question.
system, hasSystem := cmd.Params["system"].(string)
historyRaw, hasHistory := cmd.Params["history_raw"].(string)
if hasSystem || hasHistory {
messages := make([]map[string]interface{}, 0, 4)
if hasSystem && system != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": system})
}
if hasHistory && historyRaw != "" {
delimiter, _ := cmd.Params["history_delimiter"].(string)
turns, err := parseHistory(historyRaw, delimiter)
if err != nil {
return nil, fmt.Errorf("CHAT COMPLETIONS history: %w", err)
}
for _, t := range turns {
messages = append(messages, map[string]interface{}{
"role": t["role"],
"content": t["content"],
})
}
}
messages = append(messages, map[string]interface{}{"role": "user", "content": question})
body["messages"] = messages
} else {
body["question"] = question
}
// Optional flags — only emit when explicitly set
if isSet(cmd, "pass_all_history") && cmd.Params["pass_all_history"].(bool) {
body["pass_all_history_messages"] = true
}
if isSet(cmd, "legacy") && cmd.Params["legacy"].(bool) {
body["legacy"] = true
}
// Generation params — only emit when explicitly set
if isSet(cmd, "temperature") {
body["temperature"] = cmd.Params["temperature"]
}
if isSet(cmd, "max_tokens") {
body["max_tokens"] = cmd.Params["max_tokens"]
}
if isSet(cmd, "top_p") {
body["top_p"] = cmd.Params["top_p"]
}
if isSet(cmd, "frequency_penalty") {
body["frequency_penalty"] = cmd.Params["frequency_penalty"]
}
if isSet(cmd, "presence_penalty") {
body["presence_penalty"] = cmd.Params["presence_penalty"]
}
return body, nil
}
// oneshotChatCompletions performs a non-streaming POST and returns a
// ChatCompletionsResponse parsed from the RAGFlow-internal JSON envelope.
func (c *CLI) oneshotChatCompletions(url string, body map[string]interface{}) (ResponseIf, error) {
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
resp, err := httpClient.Request("POST", url, "web", nil, body)
if err != nil {
return nil, fmt.Errorf("chat completions request: %w", err)
}
if resp.StatusCode != 200 {
return &ChatCompletionsResponse{
Code: resp.StatusCode,
Message: string(resp.Body),
raw: resp.Body,
}, nil
}
out := &ChatCompletionsResponse{
Duration: resp.Duration,
raw: resp.Body,
}
// RAGFlow returns {code, data: {answer, reference, ...}, message}.
var envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data *chatCompletionData `json:"data"`
}
if err := json.Unmarshal(resp.Body, &envelope); err != nil {
return nil, fmt.Errorf("chat completions: invalid response JSON: %w", err)
}
out.Code = envelope.Code
out.Message = envelope.Message
out.Data = envelope.Data
return out, nil
}
// streamChatCompletions performs a streaming POST and collects SSE chunks.
func (c *CLI) streamChatCompletions(url string, body map[string]interface{}) (ResponseIf, error) {
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
reader, err := httpClient.RequestStream("POST", url, "web", nil, body)
if err != nil {
return nil, fmt.Errorf("chat completions stream: %w", err)
}
defer reader.Close()
start := time.Now()
scanner := bufio.NewScanner(reader)
var fullContent string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimPrefix(line, "data:")
payload = strings.TrimSpace(payload)
if payload == "[DONE]" {
continue
}
var chunk struct {
Code int `json:"code"`
Message string `json:"message"`
Data chatCompletionData `json:"data"`
}
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if chunk.Data.Answer != "" {
fullContent += chunk.Data.Answer
}
}
fullContent = strings.TrimLeft(fullContent, "\n\r")
return &ChatCompletionsResponse{
Duration: time.Since(start).Seconds(),
Data: &chatCompletionData{
Answer: fullContent,
},
streamed: true,
}, nil
}

View File

@@ -2702,6 +2702,13 @@ func (p *Parser) parseAPIDisable() (*Command, error) {
func (p *Parser) parseAPIChat() (*Command, error) {
p.nextToken() // consume CHAT
// Redirect "chat completion[s]" to the standalone chat completions parser.
if p.curToken.Type == TokenIdentifier &&
(strings.EqualFold(p.curToken.Value, "completion") || strings.EqualFold(p.curToken.Value, "completions")) {
p.nextToken() // consume completion/completions
return p.parseChatCompletionsBody()
}
var err error
var modelNameOrID string = ""
var messages []string
@@ -3759,6 +3766,150 @@ optionsLoop:
return cmd, nil
}
// CHAT COMPLETIONS <question>
// [chat_id <string>] [session <string>] [llm <string>]
// parseChatCompletionsBody parses the question and options of a CHAT COMPLETIONS
// command. The leading keyword(s) must already have been consumed by the caller.
func (p *Parser) parseChatCompletionsBody() (*Command, error) {
if p.curToken.Type == TokenDash {
dashCount := 0
for p.curToken.Type == TokenDash {
dashCount++
p.nextToken()
}
if dashCount > 0 && p.curToken.Type == TokenIdentifier {
switch strings.ToLower(p.curToken.Value) {
case "h", "help":
return NewCommand("chat completions help"), nil
}
}
return nil, fmt.Errorf("CHAT COMPLETIONS: only -h/--help takes no args; otherwise expected question")
}
cmd := NewCommand("chat completions")
// Defaults
cmd.Params["chat_id"] = ""
cmd.Params["temperature"] = 0.0
cmd.Params["max_tokens"] = 0
cmd.Params["stream"] = false
// Track which options were explicitly set (distinguishes from defaults).
cmd.Params["_set"] = map[string]bool{}
// Required positional: <question>
question, err := p.parseQuotedString()
if err != nil {
return nil, fmt.Errorf("CHAT COMPLETIONS: expected question: %w", err)
}
cmd.Params["question"] = question
p.nextToken()
// Optional named options
handleOption := func(name string) error {
switch name {
case "chat_id", "session", "llm", "system":
v, err := p.parseQuotedString()
if err != nil {
return fmt.Errorf("CHAT COMPLETIONS %s: expected quoted string, got %s", name, p.curToken.Value)
}
cmd.Params[name] = v
p.nextToken()
markSet(cmd, name)
case "temperature", "top_p", "frequency_penalty", "presence_penalty":
v, err := p.parseFloat()
if err != nil {
return fmt.Errorf("CHAT COMPLETIONS %s: expected number, got %s", name, p.curToken.Value)
}
cmd.Params[name] = v
p.nextToken()
markSet(cmd, name)
case "max_tokens":
v, err := p.parseNumber()
if err != nil {
return fmt.Errorf("CHAT COMPLETIONS max_tokens: expected integer, got %s", p.curToken.Value)
}
cmd.Params["max_tokens"] = v
p.nextToken()
markSet(cmd, "max_tokens")
case "stream", "pass_all_history", "legacy":
v, err := p.parseBool()
if err != nil {
return fmt.Errorf("CHAT COMPLETIONS %s: expected true|false, got %s", name, p.curToken.Value)
}
cmd.Params[name] = v
markSet(cmd, name)
case "history":
raw, err := p.parseQuotedString()
if err != nil {
return fmt.Errorf("CHAT COMPLETIONS history: expected quoted string, got %s", p.curToken.Value)
}
cmd.Params["history_raw"] = raw
p.nextToken()
case "history_delimiter":
v, err := p.parseQuotedString()
if err != nil {
return fmt.Errorf("CHAT COMPLETIONS history_delimiter: expected quoted string, got %s", p.curToken.Value)
}
cmd.Params["history_delimiter"] = v
p.nextToken()
default:
return fmt.Errorf("CHAT COMPLETIONS: unknown option %q (valid: chat_id, session, llm, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, pass_all_history, legacy)", name)
}
return nil
}
// Named options, any order, until ';'.
optionsLoop:
for {
switch p.curToken.Type {
case TokenSemicolon:
p.nextToken()
break optionsLoop
case TokenEOF:
break optionsLoop
case TokenIdentifier, TokenQuotedString:
name := p.curToken.Value
if p.curToken.Type == TokenQuotedString {
name = strings.Trim(name, "'\"")
}
p.nextToken()
if err := handleOption(name); err != nil {
return nil, err
}
default:
if !isKeyword(p.curToken.Type) {
return nil, fmt.Errorf("CHAT COMPLETIONS: unexpected token %q in option list (valid options: chat_id, session, llm, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, pass_all_history, legacy)", p.curToken.Value)
}
name := p.curToken.Value
p.nextToken()
if err := handleOption(name); err != nil {
return nil, err
}
}
}
return cmd, nil
}
func markSet(cmd *Command, name string) {
if s, ok := cmd.Params["_set"].(map[string]bool); ok {
s[name] = true
}
}
func isSet(cmd *Command, name string) bool {
if s, ok := cmd.Params["_set"].(map[string]bool); ok {
return s[name]
}
return false
}
// parseJSONLiteral consumes a TokenQuotedString whose payload is a JSON
// value (object, array, string, number, or boolean) and returns it as
// the original raw string (NOT decoded — the caller decides whether to