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

@@ -1,8 +1,10 @@
package cli
import (
"encoding/json"
"fmt"
"ragflow/internal/common"
"regexp"
"strconv"
"strings"
)
@@ -2799,6 +2801,16 @@ func (p *Parser) parseRetrieveCommand() (*Command, error) {
p.nextToken()
}
continue
} else if p.curToken.Type == TokenIllegal && p.curToken.Value == "." {
if cmd.Params["path"] == nil {
cmd.Params["path"] = "."
} else {
cmd.Params["path"] = fmt.Sprintf("%s.", cmd.Params["path"])
}
p.nextToken()
continue
} else {
return nil, fmt.Errorf("unexpected token %q in search path", p.curToken.Value)
}
}
return cmd, nil
@@ -4750,3 +4762,252 @@ func (p *Parser) parseChunkCommand(explain bool) (*Command, error) {
return cmd, nil
}
// parseOpenaiChatCommand parses:
//
// OPENAI_CHAT <chat_id> <message>
// [model <string>] [system <string>]
// [history <string>] [history_delimiter <string>]
// [temperature <float>] [max_tokens <int>] [stream <bool>]
// [top_p <float>] [frequency_penalty <float>] [presence_penalty <float>]
// [extra_body <json>]
// ;
//
// Named options can appear in any order. The chat_id and message are
// required positional args; everything else is optional with a default.
//
// `history` is captured as a single string in cmd.Params["history_raw"]
// and is split into turns by cmd.Params["history_delimiter"] (default
// ";") later in buildOpenaiChatRequestBody — this two-step split lets
// `history_delimiter` and `history` appear in either order on the
// command line. The chosen delimiter must not appear inside any
// message body.
//
// `extra_body` is well-formed JSON. The accepted keys are:
//
// reference bool
// reference_metadata { include?: bool, fields?: string[] }
// metadata_condition { logic?: "and"|"or", conditions?: [{key, operator, value}] }
// (See user_command.go:allowedExtraBodyKeys for the authoritative set)
func (p *Parser) parseOpenaiChatCommand() (*Command, error) {
p.nextToken() // consume OPENAI_CHAT
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("openai_chat_help"), nil
}
}
return nil, fmt.Errorf("OPENAI_CHAT: only -h/--help takes no args; otherwise expected chat_id and message")
}
cmd := NewCommand("openai_chat")
// Defaults — match the OpenAI spec / RAGFlow server behavior.
cmd.Params["model"] = "model" // placeholder; server resolves to dialog.llm_id
cmd.Params["temperature"] = 0.0
cmd.Params["max_tokens"] = 0
cmd.Params["stream"] = false
// Required positional: <chat_id> <message>
chatID, err := p.parseQuotedString()
if err != nil {
return nil, fmt.Errorf("OPENAI_CHAT: expected chat_id as first argument: %w", err)
}
cmd.Params["chat_id"] = chatID
p.nextToken()
message, err := p.parseQuotedString()
if err != nil {
return nil, fmt.Errorf("OPENAI_CHAT: expected message as second argument: %w", err)
}
cmd.Params["message"] = message
p.nextToken()
// Optional
handleOption := func(name string) error {
switch name {
case "model", "system":
v, err := p.parseQuotedString()
if err != nil {
return fmt.Errorf("OPENAI_CHAT %s: expected quoted string, got %s", name, p.curToken.Value)
}
cmd.Params[name] = v
p.nextToken()
case "temperature", "top_p", "frequency_penalty", "presence_penalty":
v, err := p.parseFloat()
if err != nil {
return fmt.Errorf("OPENAI_CHAT %s: expected number, got %s", name, p.curToken.Value)
}
cmd.Params[name] = v
p.nextToken()
case "max_tokens":
v, err := p.parseNumber()
if err != nil {
return fmt.Errorf("OPENAI_CHAT max_tokens: expected integer, got %s", p.curToken.Value)
}
cmd.Params["max_tokens"] = v
p.nextToken()
case "stream":
v, err := p.parseBool()
if err != nil {
return fmt.Errorf("OPENAI_CHAT %s: expected true|false, got %s", name, p.curToken.Value)
}
cmd.Params[name] = v
// parseBool already advances the cursor.
case "extra_body":
raw, err := p.parseJSONLiteral()
if err != nil {
return fmt.Errorf("OPENAI_CHAT %s: %w", name, err)
}
cmd.Params[name] = raw
p.nextToken()
case "history":
raw, err := p.parseQuotedString()
if err != nil {
return fmt.Errorf("OPENAI_CHAT 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("OPENAI_CHAT history_delimiter: expected quoted string, got %s", p.curToken.Value)
}
cmd.Params["history_delimiter"] = v
p.nextToken()
default:
return fmt.Errorf("OPENAI_CHAT: unknown option %q (valid: model, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, extra_body)", 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("OPENAI_CHAT: unexpected token %q in option list (valid options: model, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, extra_body)", p.curToken.Value)
}
name := p.curToken.Value
p.nextToken()
if err := handleOption(name); err != nil {
return nil, err
}
}
}
return cmd, nil
}
// 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
// embed it into a larger JSON object or pass it through as-is).
func (p *Parser) parseJSONLiteral() (string, error) {
if p.curToken.Type != TokenQuotedString {
return "", fmt.Errorf("expected JSON literal in single/double quotes, got %s", p.curToken.Value)
}
raw := p.curToken.Value
// Validate it actually parses as JSON so we fail fast on
// typos like `'{}' extra comma'` or `'not json'`.
var probe interface{}
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
return "", fmt.Errorf("invalid JSON literal %q: %w", raw, err)
}
return raw, nil
}
// parseBool accepts a TokenIdentifier "true"/"false"
func (p *Parser) parseBool() (bool, error) {
switch strings.ToLower(p.curToken.Value) {
case "true":
p.nextToken()
return true, nil
case "false":
p.nextToken()
return false, nil
}
return false, fmt.Errorf("expected true or false, got %q", p.curToken.Value)
}
// historyRoleRegex matches the role prefix on a turn. The captured
// alternation is the role; the colon is required so we don't
// accidentally split on a word like "user:foo" appearing inside
// other content.
var historyRoleRegex = regexp.MustCompile(`(?i)^(user|assistant):`)
// defaultHistoryDelimiter is the turn separator used when the
// caller does not pass the `history_delimiter` option.
const defaultHistoryDelimiter = ";"
// parseHistory splits the history literal into a slice of
// {"role": ..., "content": ...} maps. Format:
//
// "user:question one;assistant:answer one;user:question two"
//
// Turns are separated by `history_delimiter` (default `;`). Each
// segment must start with the role prefix `user:` or `assistant:`
// (case-insensitive).
func parseHistory(literal, delimiter string) ([]map[string]string, error) {
if delimiter == "" {
delimiter = defaultHistoryDelimiter
}
// Trim a single pair of surrounding quotes if present.
s := strings.TrimSpace(literal)
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
s = s[1 : len(s)-1]
}
}
raw := strings.Split(s, delimiter)
turns := make([]map[string]string, 0, len(raw))
for _, segment := range raw {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
m := historyRoleRegex.FindStringSubmatch(segment)
if m == nil {
return nil, fmt.Errorf("history segment %q must start with 'user:' or 'assistant:'", segment)
}
role := strings.ToLower(m[1])
// Drop the "<role>:" prefix (m[0] is the whole match, e.g.
// "user:"; we want the content AFTER the colon).
content := strings.TrimPrefix(segment, m[0])
turns = append(turns, map[string]string{
"role": role,
"content": content,
})
}
if len(turns) == 0 {
return nil, fmt.Errorf("history is empty or unparseable: %q", literal)
}
return turns, nil
}