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

@@ -754,10 +754,10 @@ Commands (User Mode):
LIST TOKENS; - List API tokens
LIST PROVIDERS; - List available LLM providers
CREATE TOKEN; - Create new API token
ADD PROVIDER 'name'; - Create a provider without API key
ADD PROVIDER 'name' 'api_key'; - Create a provider with API key
ADD PROVIDER 'name'; - Create a provider without API key
ADD PROVIDER 'name' 'api_key'; - Create a provider with API key
DROP TOKEN 'token_value'; - Delete an API token
DELETE PROVIDER 'name'; - Delete a provider
DELETE PROVIDER 'name'; - Delete a provider
SET TOKEN 'token_value'; - Set and validate API token
SHOW TOKEN; - Show current API token
SHOW PROVIDER 'name'; - Show provider details
@@ -767,6 +767,8 @@ Commands (User Mode):
USE MODEL 'provider/instance/model'; - Set current model for chat
CHAT 'message'; - Chat using current model
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)
Filesystem Commands (no quotes):
ls [path] - List resources
@@ -919,3 +921,55 @@ Datasets syntax (full filter set):
`
fmt.Println(help)
}
// printOpenaiChatHelp prints help for the OPENAI_CHAT command.
func printOpenaiChatHelp() {
help := `OPENAI_CHAT — hit POST /api/v1/openai/<chat_id>/chat/completions
Syntax:
OPENAI_CHAT 'chat_id' 'message'
[system "..."]
[history "user:...;assistant:...;user:..."]
[history_delimiter "<char>"]
[model <string>]
[temperature <float>] [max_tokens <int>] [stream <bool>]
[top_p <float>] [frequency_penalty <float>] [presence_penalty <float>]
[extra_body <json>] ;
Required positional:
'chat_id' the dialog id (becomes the URL path segment)
'message' the user message content
Named options (any order; all optional with defaults):
system '...' override the system prompt
history '...' prior turns: user:...;assistant:...;user:...
history_delimiter '...' turn separator for history (default ';')
model '...' 'model' (sentinel) or composite (default 'model')
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
extra_body <json> '{"reference":true,...}'
Defaults:
model 'model' — server resolves to the dialog's configured LLM
stream false
temperature 0
history_delimiter ';' — commas in content survive unchanged
extra_body allowlist:
reference bool
reference_metadata { include?: bool, fields?: string[] }
metadata_condition { logic?: "and"|"or", conditions?: [{key, operator, value}] }
Examples:
OPENAI_CHAT 'cid' 'Hello, how are you?';
OPENAI_CHAT 'cid' 'Hello' model 'Qwen/Qwen3-8B@ling@SILICONFLOW' temperature 0.7 max_tokens 512;
OPENAI_CHAT 'cid' 'Hello' stream true;
OPENAI_CHAT 'cid' 'next' system 'You are concise.' history 'user:q1;assistant:a1';
OPENAI_CHAT 'cid' 'Hello' extra_body '{"reference":true,"metadata_condition":{"logic":"and","conditions":[{"key":"doc_type","operator":"is","value":"faq"}]}}';
`
fmt.Println(help)
}

View File

@@ -282,6 +282,11 @@ func (c *CLI) ExecuteUserCommand(cmd *Command) (ResponseIf, error) {
return c.ChatToModel(cmd)
case "think_chat_to_model":
return c.ChatToModel(cmd)
case "openai_chat":
return c.OpenaiChat(cmd)
case "openai_chat_help":
printOpenaiChatHelp()
return nil, nil
case "embed_user_text":
return c.EmbedUserText(cmd)
case "rarank_user_document":

View File

@@ -158,12 +158,12 @@ func (c *HTTPClient) Request(method, path string, authKind string, headers map[s
return nil, err
}
defer resp.Body.Close()
duration := time.Since(startTime).Seconds()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
duration := time.Since(startTime).Seconds()
return &Response{
StatusCode: resp.StatusCode,

View File

@@ -305,6 +305,8 @@ func (l *Lexer) lookupIdent(ident string) Token {
return Token{Type: TokenChats, Value: ident}
case "CHAT":
return Token{Type: TokenChat, Value: ident}
case "OPENAI_CHAT":
return Token{Type: TokenOpenaiChat, Value: ident}
case "MESSAGE":
return Token{Type: TokenMessage, Value: ident}
case "IMAGE":

View File

@@ -206,6 +206,8 @@ func (p *Parser) parseUserCommand() (*Command, error) {
return p.parseStreamCommand()
case TokenChat:
return p.parseChatCommand()
case TokenOpenaiChat:
return p.parseOpenaiChatCommand()
case TokenThink:
return p.parseThinkCommand()
case TokenEmbed:

View File

@@ -17,6 +17,7 @@
package cli
import (
"encoding/json"
"fmt"
"strings"
)
@@ -599,3 +600,192 @@ func (r *FileSystemResponse) SetOutputFormat(format OutputFormat) { r.OutputForm
func (r *FileSystemResponse) PrintOut() {
fmt.Print(r.Output)
}
type OpenAIChatResponse struct {
Code int `json:"code,omitempty"`
Data *openAIChatData `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Duration float64 `json:"-"`
OutputFormat OutputFormat `json:"-"`
// Reasoning from the model's chain-of-thought.
Reasoning string `json:"-"`
// streamed skips the "Answer:" line in PrintOut to avoid duplication.
streamed bool
// raw HTTP body for the "raw" output format.
raw []byte
}
type openAIChatData struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []openAIChatChoice `json:"choices"`
Usage *openAIChatUsage `json:"usage"`
ReferencePayload json.RawMessage `json:"reference,omitempty"`
}
type openAIChatChoice struct {
Index int `json:"index"`
FinishReason string `json:"finish_reason"`
Logprobs interface{} `json:"logprobs"`
Message openAIChatMessage `json:"message"`
}
type openAIChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Reference json.RawMessage `json:"reference,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
type openAIChatUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
CompletionTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
AcceptedPredictionTokens int `json:"accepted_prediction_tokens"`
RejectedPredictionTokens int `json:"rejected_prediction_tokens"`
} `json:"completion_tokens_details"`
}
func (r *OpenAIChatResponse) Type() string { return "openai_chat" }
func (r *OpenAIChatResponse) TimeCost() float64 { return r.Duration }
func (r *OpenAIChatResponse) SetOutputFormat(f OutputFormat) { r.OutputFormat = f }
func (r *OpenAIChatResponse) Raw() []byte { return r.raw }
func (r *OpenAIChatResponse) SetRaw(b []byte) { r.raw = b }
func (r *OpenAIChatResponse) Content() string {
if r.Data == nil || len(r.Data.Choices) == 0 {
return ""
}
return r.Data.Choices[0].Message.Content
}
func (r *OpenAIChatResponse) Model() string {
if r.Data == nil {
return ""
}
return r.Data.Model
}
func (r *OpenAIChatResponse) Usage() *openAIChatUsage {
if r.Data == nil {
return nil
}
return r.Data.Usage
}
func (r *OpenAIChatResponse) 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.Reasoning != "" {
fmt.Printf("Thinking: %s\n", r.Reasoning)
}
if content := r.Content(); content != "" {
fmt.Printf("Answer: %s\n", content)
}
}
// Print reference chunks and their document_metadata when available.
// Reference can be on the data-level or on the message-level.
refRaw := r.Data.ReferencePayload
if len(refRaw) == 0 && len(r.Data.Choices) > 0 {
refRaw = r.Data.Choices[0].Message.Reference
}
if len(refRaw) > 0 {
printReferenceChunks(refRaw)
}
fmt.Printf("Time: %f\n", r.Duration)
}
// printReferenceChunks parses a reference JSON blob and prints each chunk
// together with its document_metadata (if any).
func printReferenceChunks(raw json.RawMessage) {
var chunks []map[string]interface{}
// direct array: [...]
if err := json.Unmarshal(raw, &chunks); err != nil {
// object with "chunks" key: {"chunks": [...], "doc_aggs": [...]}
var ref struct {
Chunks []map[string]interface{} `json:"chunks"`
}
if err2 := json.Unmarshal(raw, &ref); err2 != nil || len(ref.Chunks) == 0 {
return
}
chunks = ref.Chunks
}
if len(chunks) == 0 {
return
}
fmt.Println("Reference:")
for i, chunk := range chunks {
id := chunkID(chunk)
content := chunkContent(chunk)
docName := chunkDocName(chunk)
fmt.Printf(" [ID:%d] id=%s content=%q", i, id, truncateStr(content, 120))
if docName != "" {
fmt.Printf(" doc=%s", docName)
}
fmt.Println()
// Print document_metadata if present.
if meta, ok := chunk["document_metadata"].(map[string]interface{}); ok && len(meta) > 0 {
for k, v := range meta {
fmt.Printf(" metadata.%s = %v\n", k, v)
}
}
}
}
func chunkID(c map[string]interface{}) string {
for _, key := range []string{"chunk_id", "id"} {
if v, ok := c[key]; ok {
return fmt.Sprint(v)
}
}
return "-"
}
func chunkContent(c map[string]interface{}) string {
if v, ok := c["content"]; ok {
s := fmt.Sprint(v)
return strings.TrimSpace(s)
}
return ""
}
func chunkDocName(c map[string]interface{}) string {
if v, ok := c["document_name"]; ok {
return fmt.Sprint(v)
}
if v, ok := c["doc_name"]; ok {
return fmt.Sprint(v)
}
return ""
}
func truncateStr(s string, maxLen int) string {
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}

View File

@@ -189,6 +189,7 @@ const (
TokenPurge
TokenPlan
TokenPreview
TokenOpenaiChat
TokenLog
TokenLevel
TokenDebug

View File

@@ -32,6 +32,7 @@ import (
"ragflow/internal/ingestion"
"ragflow/internal/ingestion/parser"
"ragflow/internal/utility"
"regexp"
"strings"
"time"
)
@@ -3644,3 +3645,330 @@ func (c *CLI) ChunkCommand(cmd *Command) (ResponseIf, error) {
result.Message = fmt.Sprintf("Success to chunk %s", filename)
return &result, nil
}
// OpenaiChat dispatches the parsed OPENAI_CHAT command to either a
// non-streaming oneshot call or a streaming SSE call, depending on the
// `stream` option.
func (c *CLI) OpenaiChat(cmd *Command) (ResponseIf, error) {
if c.Config.CLIMode != APIMode {
return nil, fmt.Errorf("OPENAI_CHAT is only allowed in USER mode")
}
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
if httpClient.APIToken == nil && httpClient.LoginToken == nil {
return nil, fmt.Errorf("API token not set. Please login first")
}
body, err := buildOpenaiChatRequestBody(cmd)
if err != nil {
return nil, err
}
chatID, _ := cmd.Params["chat_id"].(string)
url := fmt.Sprintf("/openai/%s/chat/completions", chatID)
stream, _ := cmd.Params["stream"].(bool)
if stream {
return c.streamOpenaiChat(url, body)
}
return c.oneshotOpenaiChat(url, body)
}
// allowedExtraBodyKeys enumerates every top-level key the server
// accepts under `extra_body`. Anything else is rejected at CLI
// build time so the user gets a clear error before the request
// goes over the wire.
var allowedExtraBodyKeys = map[string]struct{}{
"reference": {},
"reference_metadata": {},
"metadata_condition": {},
}
// validateExtraBody checks the shape of an extra_body payload
// supplied by the user. It rejects:
//
// - Unknown top-level keys (typos and unsupported fields).
// - reference_metadata that's not an object, or whose
// sub-fields have the wrong type.
// - metadata_condition that's not an object, or whose
// conditions are missing required fields.
//
// The error message names the offending path so the user can
// fix the JSON literal in their command without having to read
// the server source.
func validateExtraBody(eb map[string]interface{}) error {
for k := range eb {
if _, ok := allowedExtraBodyKeys[k]; !ok {
return fmt.Errorf("OPENAI_CHAT extra_body: unknown field %q (valid: reference, reference_metadata, metadata_condition)", k)
}
}
// reference_metadata: { include?: bool, fields?: string[] }
if v, present := eb["reference_metadata"]; present {
rm, ok := v.(map[string]interface{})
if !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata must be an object, got %T", v)
}
if inc, ok := rm["include"]; ok {
if _, ok := inc.(bool); !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata.include must be a boolean, got %T", inc)
}
}
if fields, ok := rm["fields"]; ok {
arr, ok := fields.([]interface{})
if !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata.fields must be an array, got %T", fields)
}
for i, item := range arr {
if _, ok := item.(string); !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata.fields[%d] must be a string, got %T", i, item)
}
}
}
}
// metadata_condition: { logic?: "and"|"or", conditions?: [{key, operator, value}, ...] }
if v, present := eb["metadata_condition"]; present {
mc, ok := v.(map[string]interface{})
if !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition must be an object, got %T", v)
}
if logic, ok := mc["logic"]; ok {
s, ok := logic.(string)
if !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.logic must be a string, got %T", logic)
}
if s != "and" && s != "or" {
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.logic must be \"and\" or \"or\", got %q", s)
}
}
if conds, ok := mc["conditions"]; ok {
arr, ok := conds.([]interface{})
if !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.conditions must be an array, got %T", conds)
}
for i, item := range arr {
cond, ok := item.(map[string]interface{})
if !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.conditions[%d] must be an object, got %T", i, item)
}
if _, ok := cond["key"]; !ok {
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.conditions[%d] missing required field 'key'", i)
}
}
}
}
return nil
}
// buildOpenaiChatRequestBody assembles the JSON payload that
// /api/v1/openai/<chat_id>/chat/completions expects
//
// RAGFlow-specific knobs (e.g. `reference`, `reference_metadata`,
// `metadata_condition`) flow in via the user-supplied `extra_body`
// JSON literal, which is validated against the `allowedExtraBodyKeys`
// allowlist above before the request goes out. `stop` and `user` are
// not first-class CLI options — the Python server does not inspect
// them, and the Go server has dropped them from its request struct;
// the parser rejects them as "unknown option" so there is exactly
// one place to set them.
//
// The `messages` array is built from three optional sources, in
// this order:
// 1. `system` — single system message (if supplied)
// 2. `history` — prior turns encoded as
// "user:...,assistant:..." (if supplied)
// 3. positional <msg> — always the trailing user turn
func buildOpenaiChatRequestBody(cmd *Command) (map[string]interface{}, error) {
msg, _ := cmd.Params["message"].(string)
model, _ := cmd.Params["model"].(string)
temp, _ := cmd.Params["temperature"].(float64)
maxTokens, _ := cmd.Params["max_tokens"].(int)
stream, _ := cmd.Params["stream"].(bool)
messages := make([]map[string]interface{}, 0, 4)
if v, ok := cmd.Params["system"].(string); ok && v != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": v})
}
if v, ok := cmd.Params["history_raw"].(string); ok && v != "" {
delimiter, _ := cmd.Params["history_delimiter"].(string)
turns, err := parseHistory(v, delimiter)
if err != nil {
return nil, fmt.Errorf("OPENAI_CHAT 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": msg})
body := map[string]interface{}{
"model": model,
"messages": messages,
"stream": stream,
}
// Only emit generation params when the user actually set them
// (zero is the parser-default for "unset" and matches Python's
// behavior of dropping the field).
if temp != 0.0 {
body["temperature"] = temp
}
if maxTokens != 0 {
body["max_tokens"] = maxTokens
}
if v, ok := cmd.Params["top_p"].(float64); ok && v != 0.0 {
body["top_p"] = v
}
if v, ok := cmd.Params["frequency_penalty"].(float64); ok && v != 0.0 {
body["frequency_penalty"] = v
}
if v, ok := cmd.Params["presence_penalty"].(float64); ok && v != 0.0 {
body["presence_penalty"] = v
}
var extraBody map[string]interface{}
if v, ok := cmd.Params["extra_body"].(string); ok && v != "" {
if err := json.Unmarshal([]byte(v), &extraBody); err != nil {
return nil, fmt.Errorf("OPENAI_CHAT extra_body: invalid JSON: %w", err)
}
}
// Validate the user's extra_body against the server's accepted
// schema before the request goes over the wire.
if err := validateExtraBody(extraBody); err != nil {
return nil, err
}
if len(extraBody) > 0 {
body["extra_body"] = extraBody
}
return body, nil
}
// oneshotOpenaiChat performs a non-streaming POST and returns an
// OpenAIChatResponse parsed from the JSON envelope. It calls the
// same HTTPClient.Request used by every other CLI command.
func (c *CLI) oneshotOpenaiChat(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("openai_chat request: %w", err)
}
if resp.StatusCode != 200 {
// Python wraps errors as `{"code":..., "message":...}`. Surface
// the body verbatim so the user can read the upstream error.
return &OpenAIChatResponse{
Code: resp.StatusCode,
Message: string(resp.Body),
raw: resp.Body,
}, nil
}
out := &OpenAIChatResponse{
Duration: resp.Duration,
raw: resp.Body,
}
var wrapped struct {
Code int `json:"code"`
Message string `json:"message"`
Data *openAIChatData `json:"data"`
}
if err := json.Unmarshal(resp.Body, &wrapped); err == nil && wrapped.Data != nil {
out.Code = wrapped.Code
out.Message = wrapped.Message
out.Data = wrapped.Data
if len(wrapped.Data.Choices) > 0 {
out.Reasoning = wrapped.Data.Choices[0].Message.ReasoningContent
}
return out, nil
}
// Unwrapped (Go handler) shape.
if err := json.Unmarshal(resp.Body, &out.Data); err != nil {
return nil, fmt.Errorf("openai_chat: invalid response JSON: %w", err)
}
if out.Data != nil && len(out.Data.Choices) > 0 {
out.Reasoning = out.Data.Choices[0].Message.ReasoningContent
}
return out, nil
}
// streamOpenaiChat performs a streaming POST and prints SSE chunks to
// stdout as they arrive
func (c *CLI) streamOpenaiChat(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("openai_chat stream: %w", err)
}
if resp.StatusCode != 200 {
return &OpenAIChatResponse{
Code: resp.StatusCode,
Message: string(resp.Body),
Duration: resp.Duration,
raw: resp.Body,
}, nil
}
full := string(resp.Body)
var (
fullContent string
fullReason string
resolvedMod string
)
for _, line := range strings.Split(full, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
var chunk struct {
Model string `json:"model"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *openAIChatUsage `json:"usage"`
}
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if chunk.Model != "" {
resolvedMod = chunk.Model
}
if len(chunk.Choices) > 0 {
if d := chunk.Choices[0].Delta.Content; d != "" {
fullContent += d
}
if r := chunk.Choices[0].Delta.ReasoningContent; r != "" {
fullReason += r
}
}
}
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,
Data: &openAIChatData{
Model: resolvedMod,
Choices: []openAIChatChoice{{Message: openAIChatMessage{Content: fullContent, ReasoningContent: fullReason}}},
},
streamed: true,
raw: resp.Body,
}, 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, "")
}

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
}