mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-14 08:58:27 +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:
125
internal/handler/openai_chat.go
Normal file
125
internal/handler/openai_chat.go
Normal file
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type OpenAIChatHandler struct {
|
||||
svc *service.OpenAIChatService
|
||||
}
|
||||
|
||||
func NewOpenAIChatHandler(svc *service.OpenAIChatService) *OpenAIChatHandler {
|
||||
return &OpenAIChatHandler{svc: svc}
|
||||
}
|
||||
|
||||
// OpenAIChatCompletions handles the OpenAI-compatible chat completions route.
|
||||
// @Summary OpenAI Chat Completions
|
||||
// @Description OpenAI-compatible chat completions endpoint
|
||||
// @Tags openai
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param chat_id path string true "dialog id"
|
||||
// @Param request body service.OpenAIChatRequest true "chat completion request"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/openai/{chat_id}/chat/completions [post]
|
||||
func (h *OpenAIChatHandler) OpenAIChatCompletions(c *gin.Context) {
|
||||
chatID := c.Param("chat_id")
|
||||
if chatID == "" {
|
||||
jsonError(c, common.CodeDataError, "You don't own the chat "+chatID)
|
||||
return
|
||||
}
|
||||
|
||||
user, code, msg := GetUser(c)
|
||||
if code != common.CodeSuccess {
|
||||
jsonError(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
jsonError(c, common.CodeArgumentError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse body into the typed request
|
||||
var req service.OpenAIChatRequest
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
jsonError(c, common.CodeArgumentError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Messages presence
|
||||
if len(req.Messages) == 0 {
|
||||
jsonError(c, common.CodeDataError, "You have to provide messages.")
|
||||
return
|
||||
}
|
||||
|
||||
// extra_body shape validation
|
||||
extraBody, extraBodyOK := req.ExtraBody.(map[string]interface{})
|
||||
if req.ExtraBody != nil && !extraBodyOK {
|
||||
jsonError(c, common.CodeArgumentError, "extra_body must be an object.")
|
||||
return
|
||||
}
|
||||
|
||||
// reference_metadata shape validation
|
||||
if extraBody != nil {
|
||||
if rm, ok := extraBody["reference_metadata"].(map[string]interface{}); ok {
|
||||
if rawFields, has := rm["fields"]; has {
|
||||
if rawArr, ok := rawFields.([]interface{}); !ok {
|
||||
jsonError(c, common.CodeArgumentError, "reference_metadata.fields must be an array.")
|
||||
return
|
||||
} else {
|
||||
for _, item := range rawArr {
|
||||
if _, ok := item.(string); !ok {
|
||||
jsonError(c, common.CodeArgumentError, "reference_metadata.fields must be an array.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// metadata_condition shape validation
|
||||
if extraBody != nil {
|
||||
if mc, ok := extraBody["metadata_condition"]; ok && mc != nil {
|
||||
if _, ok := mc.(map[string]interface{}); !ok {
|
||||
jsonError(c, common.CodeArgumentError, "metadata_condition must be an object.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last message must be from the user
|
||||
if last := req.Messages[len(req.Messages)-1]; last != nil {
|
||||
if role, _ := last["role"].(string); role != "user" {
|
||||
jsonError(c, common.CodeDataError, "The last content of this conversation is not from user.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// All early-rejection checks passed. Delegate to the service for the
|
||||
// actual LLM call.
|
||||
h.svc.OpenAIChatCompletions(c, user.ID, chatID, bodyBytes)
|
||||
}
|
||||
254
internal/handler/openai_chat_test.go
Normal file
254
internal/handler/openai_chat_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
// TestNormalizeMessageContent and friends moved to
|
||||
// internal/service/openai_chat_test.go as TestService_NormalizeMessageContent_*
|
||||
// (the helpers themselves moved to the service package). TestWriteSSE
|
||||
// also moved to the service package as TestService_WriteSSE_FormatAndFlush.
|
||||
// Handler tests here focus on the HTTP boundary: rejection at parse /
|
||||
// presence / forbidden-key checks.
|
||||
|
||||
// fakeOpenAIUser injects a real *entity.User into the context so GetUser
|
||||
// succeeds. Without this, the handler short-circuits with
|
||||
// "User not found" before any validation runs.
|
||||
func fakeOpenAIUser(c *gin.Context) {
|
||||
c.Set("user", &entity.User{ID: "u1", Email: "u@x"})
|
||||
}
|
||||
|
||||
// newOpenAITestContext builds a test context with a real user, the
|
||||
// chat_id path param, and a POST request carrying the given JSON body.
|
||||
func newOpenAITestContext(t *testing.T, chatID, body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest(http.MethodPost,
|
||||
"/api/v1/openai/"+chatID+"/chat/completions",
|
||||
bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = gin.Params{{Key: "chat_id", Value: chatID}}
|
||||
fakeOpenAIUser(c)
|
||||
return c, w
|
||||
}
|
||||
|
||||
// TestChatCompletions_RejectsMissingMessages pins down the validation
|
||||
// rule "You have to provide messages." (openai_api.py:255-256). The
|
||||
// peek-and-discard parse in the handler (see OpenAIChatCompletions)
|
||||
// rejects this BEFORE the service is called, so the test doesn't
|
||||
// need a DB.
|
||||
func TestChatCompletions_RejectsMissingMessages(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{"model":"model"}`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200 (Python convention), got %d", w.Code)
|
||||
}
|
||||
respBody := w.Body.String()
|
||||
if !strings.Contains(respBody, "You have to provide messages.") {
|
||||
t.Fatalf("expected 'You have to provide messages.' in body, got %s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_DefaultsMissingModelToModel pins the
|
||||
// Go-specific behavior: `model` is OPTIONAL on the openai_chat
|
||||
// endpoint. If absent or empty, the handler injects the OpenAI
|
||||
// compat sentinel "model" (which the service resolves to the
|
||||
// dialog's default LLM). Python enforces the
|
||||
// OpenAI spec strictly via @validate_request("model", "messages")
|
||||
// at openai_api.py:237; the Go side intentionally relaxes that
|
||||
// so callers can use the dialog's default without typing
|
||||
// `"model": "model"` explicitly.
|
||||
//
|
||||
// The handler's check is the "model" is defaulted, not the
|
||||
// service's success. We recover from the service's expected DB
|
||||
// panic and only assert on what the handler wrote.
|
||||
func TestChatCompletions_DefaultsMissingModelToModel(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1",
|
||||
`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||
|
||||
// The handler should accept the request and call the service.
|
||||
// The service will panic on DB access (no DB in tests); we
|
||||
// recover so the test only asserts the handler's behavior.
|
||||
func() {
|
||||
defer func() {
|
||||
_ = recover() // expected: service panicked on DB call
|
||||
}()
|
||||
h.OpenAIChatCompletions(c)
|
||||
}()
|
||||
|
||||
// The handler must NOT have written a rejection response
|
||||
// before the service panicked. If the response body has
|
||||
// "You have to provide messages.", the messages-presence
|
||||
// check fired by mistake (it shouldn't, since messages IS
|
||||
// present).
|
||||
respBody := w.Body.String()
|
||||
if strings.Contains(respBody, "You have to provide messages.") {
|
||||
t.Fatalf("missing model should be defaulted, not rejected; got: %s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_RejectsBadExtraBody pins down the validation
|
||||
// rule "extra_body must be an object." (openai_api.py:243).
|
||||
func TestChatCompletions_RejectsBadExtraBody(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{
|
||||
"model": "model",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"extra_body": "not an object"
|
||||
}`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
respBody := w.Body.String()
|
||||
if !strings.Contains(respBody, "extra_body must be an object.") {
|
||||
t.Fatalf("expected 'extra_body must be an object.' in body, got %s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_RejectsBadMetadataCondition pins down the validation
|
||||
// rule "metadata_condition must be an object." (openai_api.py:287).
|
||||
func TestChatCompletions_RejectsBadMetadataCondition(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{
|
||||
"model": "model",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"extra_body": {"metadata_condition": "bad"}
|
||||
}`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
respBody := w.Body.String()
|
||||
if !strings.Contains(respBody, "metadata_condition must be an object.") {
|
||||
t.Fatalf("expected 'metadata_condition must be an object.' in body, got %s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_RejectsBadReferenceMetadataFields pins down the
|
||||
// validation rule "reference_metadata.fields must be an array." (openai_api.py:251).
|
||||
func TestChatCompletions_RejectsBadReferenceMetadataFields(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{
|
||||
"model": "model",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"extra_body": {"reference_metadata": {"fields": "author"}}
|
||||
}`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
respBody := w.Body.String()
|
||||
if !strings.Contains(respBody, "reference_metadata.fields must be an array.") {
|
||||
t.Fatalf("expected 'reference_metadata.fields must be an array.' in body, got %s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_RejectsLastMessageNotUser pins down the validation
|
||||
// rule "The last content of this conversation is not from user." (openai_api.py:261).
|
||||
func TestChatCompletions_RejectsLastMessageNotUser(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{
|
||||
"model": "model",
|
||||
"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "world"}]
|
||||
}`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
respBody := w.Body.String()
|
||||
if !strings.Contains(respBody, "The last content of this conversation is not from user.") {
|
||||
t.Fatalf("expected 'The last content of this conversation is not from user.' in body, got %s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_RejectsInvalidJSON pins down the JSON-parse failure
|
||||
// path. We expect a 4xx-ish error code (Gin's binding error returns
|
||||
// 400-equivalent message; we accept any non-empty error).
|
||||
func TestChatCompletions_RejectsInvalidJSON(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{ not json`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
if w.Body.Len() == 0 {
|
||||
t.Fatalf("expected non-empty error body for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_SilentlyDropsTopLevelStop verifies that top-level
|
||||
// `stop` is silently dropped rather than rejected. The field is not declared
|
||||
// on OpenAIChatRequest, so Go's json.Unmarshal discards it — matching the
|
||||
// OpenAI server convention of ignoring unknown request fields. The CLI parser
|
||||
// rejects `stop` at parse time for CLI callers.
|
||||
//
|
||||
// The payload ends with an assistant turn so validation trips the early
|
||||
// "last content not from user" rejector before reaching the DB. The
|
||||
// rejection message proves we got past the stop check; the absence of
|
||||
// "not supported" proves the field was silently dropped.
|
||||
func TestChatCompletions_SilentlyDropsTopLevelStop(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{
|
||||
"model": "model",
|
||||
"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "world"}],
|
||||
"stop": ["END"]
|
||||
}`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
respBody := w.Body.String()
|
||||
if strings.Contains(respBody, "not supported") {
|
||||
t.Fatalf("did not expect 'stop' rejection, got %s", respBody)
|
||||
}
|
||||
if !strings.Contains(respBody, "The last content of this conversation is not from user.") {
|
||||
t.Fatalf("expected request to flow past stop check to last-message validator, got %s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_SilentlyDropsTopLevelUser verifies that top-level
|
||||
// `user` is silently dropped (same rationale and structure as `stop` above).
|
||||
func TestChatCompletions_SilentlyDropsTopLevelUser(t *testing.T) {
|
||||
h := NewOpenAIChatHandler(service.NewOpenAIChatService())
|
||||
c, w := newOpenAITestContext(t, "c1", `{
|
||||
"model": "model",
|
||||
"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "world"}],
|
||||
"user": "session-abc"
|
||||
}`)
|
||||
|
||||
h.OpenAIChatCompletions(c)
|
||||
|
||||
respBody := w.Body.String()
|
||||
if strings.Contains(respBody, "not supported") {
|
||||
t.Fatalf("did not expect 'user' rejection, got %s", respBody)
|
||||
}
|
||||
if !strings.Contains(respBody, "The last content of this conversation is not from user.") {
|
||||
t.Fatalf("expected request to flow past user check to last-message validator, got %s", respBody)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user