mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
### 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
255 lines
9.7 KiB
Go
255 lines
9.7 KiB
Go
//
|
|
// 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)
|
|
}
|
|
}
|