Files
ragflow/internal/handler/openai_chat_test.go
qinling0210 563d855780 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
2026-06-18 18:07:27 +08:00

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)
}
}