mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-15 09:28:27 +08:00
Implement chat completions in go (#16491)
### Summary POST /api/v1/chat/completions
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user