mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-26 18:33:30 +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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ const (
|
||||
TokenPurge
|
||||
TokenPlan
|
||||
TokenPreview
|
||||
TokenOpenaiChat
|
||||
TokenLog
|
||||
TokenLevel
|
||||
TokenDebug
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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