mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-19 22:21:04 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user