Files
ragflow/internal/entity/models/longcat_test.go
Dexterity 04aa8d04e8 fix(go-models): raise SSE scanner buffer so large stream chunks are not dropped (#15382)
### Summary

Closes #15381 

Every provider in `internal/entity/models/` reads its streaming response
with `bufio.NewScanner(resp.Body)` and iterates over `scanner.Scan()`.
The default `bufio.Scanner` maximum token size is 64KB, so when an
upstream sends a single SSE `data:` line larger than 64KB (long content
deltas, large tool or function call argument blobs, bundled
`reasoning_content`, or providers that emit a whole message in one
event) `scanner.Scan()` returns `false` and `scanner.Err()` returns
`bufio.ErrTooLong`. Streaming chat then ends with an error partway
through the response.

This change adds `scanner.Buffer(make([]byte, 64*1024), 1024*1024)`
immediately after every SSE scanner that was still bare, raising the cap
to 1MB. 1MB is the value already used for streaming chat in `openai.go`,
`modelscope.go`, `groq.go`, `mistral.go`, `xai.go` and the other already
patched providers (the 8MB cap in the repo is reserved for TTS and
embedding paths), so this simply converges the remaining providers onto
the established pattern. Nothing else changes: line parsing, `data:`
prefix handling, `[DONE]` detection, JSON unmarshalling, error handling,
and the existing `scanner.Err()` checks all stay the same.

Providers covered (23 scanners across 22 files): 302ai, aliyun,
baichuan, baidu, cohere, deepinfra, deepseek, gitee, huggingface,
lmstudio, minimax (the chat scanner, whose TTS scanner was already
bumped), moonshot, nvidia, ollama, openrouter, orcarouter, paddleocr,
siliconflow, tokenhub, vllm, volcengine, xunfei, zhipu-ai. `jiekouai.go`
is excluded because it is covered by the in flight #15337.

A table driven regression test (`sse_scanner_buffer_test.go`) streams a
single 128KB `data:` content delta followed by `data: [DONE]` through an
`httptest` server and asserts that `ChatStreamlyWithSender` delivers the
full content with no error across a representative subset of providers.
Without the buffer fix the test fails with `bufio.Scanner: token too
long`.

This PR also removes three duplicate declarations of the package level
`roundTripperFunc` test helper that several recently merged provider PRs
each added independently, which had left the `internal/entity/models`
test package unable to compile. The helper now lives in a single place
and is shared.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2026-05-29 19:34:00 +08:00

553 lines
19 KiB
Go

package models
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newLongCatServer(t *testing.T, expectedPath string, handler func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != expectedPath {
t.Errorf("expected path=%s, got %s", expectedPath, r.URL.Path)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("expected Authorization=Bearer test-key, got %q", got)
return
}
if r.Method == http.MethodPost {
// Accept "application/json" with or without a parameter
// suffix like "; charset=utf-8" — both are valid JSON.
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Errorf("expected Content-Type to start with application/json, got %q", got)
return
}
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
return
}
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Errorf("unmarshal: %v\nraw=%s", err, string(raw))
return
}
handler(t, r, body, w)
return
}
handler(t, r, nil, w)
}))
}
func newLongCatForTest(baseURL string) *LongCatModel {
return NewLongCatModel(
map[string]string{"default": baseURL},
URLSuffix{Chat: "openai/v1/chat/completions", Models: "openai/v1/models"},
)
}
// newLongCatSSEServer returns an httptest.Server that asserts the
// request contract (POST + path + Authorization + Content-Type prefix)
// before writing the supplied SSE payload. Used by the streaming tests
// so a regression in the wire shape can't slip through unnoticed.
func newLongCatSSEServer(t *testing.T, expectedPath, ssePayload string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
return
}
if r.URL.Path != expectedPath {
t.Errorf("expected path=%s, got %s", expectedPath, r.URL.Path)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("expected Authorization=Bearer test-key, got %q", got)
return
}
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Errorf("expected Content-Type to start with application/json, got %q", got)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, ssePayload)
}))
}
func TestLongCatName(t *testing.T) {
if got := newLongCatForTest("http://unused").Name(); got != "longcat" {
t.Errorf("Name()=%q, want %q", got, "longcat")
}
}
func TestLongCatNewModelWithCustomDefaultTransport(t *testing.T) {
original := http.DefaultTransport
http.DefaultTransport = roundTripperFunc(func(*http.Request) (*http.Response, error) {
return nil, nil
})
t.Cleanup(func() {
http.DefaultTransport = original
})
if model := NewLongCatModel(map[string]string{"default": "http://unused"}, URLSuffix{}); model == nil {
t.Fatal("NewLongCatModel returned nil")
}
}
func TestLongCatChatHappyPath(t *testing.T) {
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "LongCat-Flash-Chat" {
t.Errorf("model=%v", body["model"])
}
if body["stream"] != false {
t.Errorf("stream=%v want false", body["stream"])
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"choices": []map[string]interface{}{{
"message": map[string]interface{}{"content": "pong"},
}},
})
})
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
resp, err := m.ChatWithMessages("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "ping"}},
&APIConfig{ApiKey: &apiKey}, nil)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if resp.Answer == nil || resp.ReasonContent == nil {
t.Fatalf("Answer/ReasonContent must be non-nil pointers, got Answer=%v ReasonContent=%v", resp.Answer, resp.ReasonContent)
}
if *resp.Answer != "pong" {
t.Errorf("answer=%q want pong", *resp.Answer)
}
if *resp.ReasonContent != "" {
t.Errorf("ReasonContent=%q want empty", *resp.ReasonContent)
}
}
func TestLongCatChatExtractsReasoningContent(t *testing.T) {
// LongCat-Flash-Thinking returns the chain-of-thought in
// message.reasoning_content (OpenAI o-series shape). Live-probed
// against api.longcat.chat; the fixture mimics the actual response
// shape captured there.
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "LongCat-Flash-Thinking" {
t.Errorf("model=%v", body["model"])
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"choices": []map[string]interface{}{{
"message": map[string]interface{}{
"role": "assistant",
"content": "15% of 80 is 12.",
"reasoning_content": "We need to compute 15% of 80. 0.15 * 80 = 12.",
},
}},
})
})
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
resp, err := m.ChatWithMessages("LongCat-Flash-Thinking",
[]Message{{Role: "user", Content: "15% of 80?"}},
&APIConfig{ApiKey: &apiKey}, nil)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if resp.Answer == nil || resp.ReasonContent == nil {
t.Fatalf("Answer/ReasonContent must be non-nil pointers, got Answer=%v ReasonContent=%v", resp.Answer, resp.ReasonContent)
}
if *resp.Answer != "15% of 80 is 12." {
t.Errorf("Answer=%q", *resp.Answer)
}
if *resp.ReasonContent != "We need to compute 15% of 80. 0.15 * 80 = 12." {
t.Errorf("ReasonContent=%q", *resp.ReasonContent)
}
}
// TestLongCatChatDropsUndocumentedFields guards against re-introducing
// stop / reasoning_effort / response_format / tools etc. The LongCat
// docs only list model, messages, stream, max_tokens, temperature,
// top_p — anything else is undocumented and must not be sent, since
// the maintainer specifically flagged this on PR #14809.
func TestLongCatChatDropsUndocumentedFields(t *testing.T) {
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
for _, k := range []string{"stop", "reasoning_effort", "response_format", "tools", "tool_choice", "presence_penalty", "frequency_penalty", "n", "logprobs"} {
if _, present := body[k]; present {
t.Errorf("undocumented field %q must not be sent: %v", k, body[k])
}
}
// Documented fields, on the other hand, MUST be forwarded when set.
for _, k := range []string{"model", "messages", "stream", "max_tokens", "temperature", "top_p"} {
if _, present := body[k]; !present {
t.Errorf("documented field %q missing from request body", k)
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"choices": []map[string]interface{}{{
"message": map[string]interface{}{"content": "ok"},
}},
})
})
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
mt := 32
temp := 0.7
topP := 0.9
stop := []string{"END"}
effort := "high"
_, err := m.ChatWithMessages("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey},
// Deliberately pass Stop/Effort to prove they are filtered out.
&ChatConfig{MaxTokens: &mt, Temperature: &temp, TopP: &topP, Stop: &stop, Effort: &effort})
if err != nil {
t.Fatalf("Chat: %v", err)
}
}
func TestLongCatChatRequiresAPIKey(t *testing.T) {
m := newLongCatForTest("http://unused")
_, err := m.ChatWithMessages("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{}, nil)
if err == nil || !strings.Contains(err.Error(), "api key is required") {
t.Errorf("expected api-key error, got %v", err)
}
}
func TestLongCatChatRequiresMessages(t *testing.T) {
m := newLongCatForTest("http://unused")
apiKey := "test-key"
_, err := m.ChatWithMessages("LongCat-Flash-Chat", nil, &APIConfig{ApiKey: &apiKey}, nil)
if err == nil || !strings.Contains(err.Error(), "messages is empty") {
t.Errorf("expected messages-empty error, got %v", err)
}
}
func TestLongCatChatRejectsHTTPError(t *testing.T) {
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
})
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
_, err := m.ChatWithMessages("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey}, nil)
if err == nil || !strings.Contains(err.Error(), "401") {
t.Errorf("expected 401 propagated, got %v", err)
}
}
func TestLongCatStreamHappyPath(t *testing.T) {
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"content":" world"},"finish_reason":"stop"}]}`+"\n"+
`data: [DONE]`+"\n",
)
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
var chunks []string
var sawDone bool
err := m.ChatStreamlyWithSender("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "hi"}},
&APIConfig{ApiKey: &apiKey}, nil,
func(c *string, _ *string) error {
if c == nil {
return nil
}
if *c == "[DONE]" {
sawDone = true
return nil
}
chunks = append(chunks, *c)
return nil
})
if err != nil {
t.Fatalf("stream: %v", err)
}
if strings.Join(chunks, "") != "Hello world" {
t.Errorf("content=%v", chunks)
}
if !sawDone {
t.Error("expected [DONE] sentinel")
}
}
func TestLongCatStreamExtractsReasoningContent(t *testing.T) {
// Fixture matches the shape captured live from
// LongCat-Flash-Thinking against api.longcat.chat: deltas
// interleave reasoning_content and content within the stream.
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"reasoning_content":"step 1. "}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"reasoning_content":"step 2."}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"content":"final answer"},"finish_reason":"stop"}]}`+"\n"+
`data: [DONE]`+"\n",
)
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
var content, reasoning []string
err := m.ChatStreamlyWithSender("LongCat-Flash-Thinking",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey}, nil,
func(c *string, r *string) error {
if c != nil && r != nil {
t.Errorf("sender called with both args non-nil")
}
if r != nil && *r != "" {
reasoning = append(reasoning, *r)
}
if c != nil && *c != "" && *c != "[DONE]" {
content = append(content, *c)
}
return nil
})
if err != nil {
t.Fatalf("stream: %v", err)
}
if got := strings.Join(reasoning, ""); got != "step 1. step 2." {
t.Errorf("reasoning=%q", got)
}
if got := strings.Join(content, ""); got != "final answer" {
t.Errorf("content=%q", got)
}
}
func TestLongCatStreamRejectsExplicitFalse(t *testing.T) {
m := newLongCatForTest("http://unused")
apiKey := "test-key"
stream := false
err := m.ChatStreamlyWithSender("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey},
&ChatConfig{Stream: &stream},
func(*string, *string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "stream must be true") {
t.Errorf("expected stream-true guard, got %v", err)
}
}
func TestLongCatStreamRequiresSender(t *testing.T) {
m := newLongCatForTest("http://unused")
apiKey := "test-key"
err := m.ChatStreamlyWithSender("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey}, nil, nil)
if err == nil || !strings.Contains(err.Error(), "sender is required") {
t.Errorf("expected sender-required error, got %v", err)
}
}
func TestLongCatStreamFailsWithoutTerminal(t *testing.T) {
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"half"}}]}`+"\n",
)
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
err := m.ChatStreamlyWithSender("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey}, nil,
func(*string, *string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "stream ended before") {
t.Errorf("expected truncation error, got %v", err)
}
}
// A malformed SSE frame (invalid JSON) used to be silently skipped,
// which masked truncated or corrupted streams. The driver must now
// fail hard with a "longcat: invalid SSE event" wrapper.
func TestLongCatStreamRejectsMalformedFrame(t *testing.T) {
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+
`data: {this is not valid json}`+"\n",
)
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
err := m.ChatStreamlyWithSender("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey}, nil,
func(*string, *string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "invalid SSE event") {
t.Errorf("expected invalid-SSE error, got %v", err)
}
}
// An upstream {"error": ...} frame mid-stream used to fall through to
// the "no choices" continue and leave the caller with a generic
// truncation error. The driver must surface the upstream error verbatim.
func TestLongCatStreamSurfacesUpstreamError(t *testing.T) {
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+
`data: {"error":{"message":"rate limit exceeded","type":"rate_limit_error"}}`+"\n",
)
defer srv.Close()
m := newLongCatForTest(srv.URL)
apiKey := "test-key"
err := m.ChatStreamlyWithSender("LongCat-Flash-Chat",
[]Message{{Role: "user", Content: "x"}},
&APIConfig{ApiKey: &apiKey}, nil,
func(*string, *string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "upstream stream error") {
t.Errorf("expected upstream-error surfacing, got %v", err)
}
if err != nil && !strings.Contains(err.Error(), "rate limit") {
t.Errorf("expected upstream message included, got %v", err)
}
}
func TestLongCatListModelsAndCheckConnection(t *testing.T) {
var requests int
srv := newLongCatServer(t, "/openai/v1/models", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
requests++
if body != nil {
t.Errorf("GET /models should not send a JSON body: %v", body)
}
if r.ContentLength > 0 {
t.Errorf("GET /models should not send request body with ContentLength=%d", r.ContentLength)
}
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read GET body: %v", err)
return
}
if len(raw) > 0 {
t.Errorf("GET /models should not send request body: %q", string(raw))
}
if requests == 1 {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"object": "list",
"data": []map[string]interface{}{
{"id": "LongCat-Flash-Chat", "object": "model"},
{"id": "LongCat-Flash-Thinking-2601", "object": "model"},
{"id": "", "object": "model"},
},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"object": "list",
"data": []map[string]interface{}{
{"id": "LongCat-Flash-Chat", "object": "model"},
},
})
})
defer srv.Close()
apiKey := "test-key"
models, err := newLongCatForTest(srv.URL).ListModels(&APIConfig{ApiKey: &apiKey})
if err != nil {
t.Fatalf("ListModels: %v", err)
}
if got := strings.Join(models, ","); got != "LongCat-Flash-Chat,LongCat-Flash-Thinking-2601" {
t.Errorf("models=%q", got)
}
if err := newLongCatForTest(srv.URL).CheckConnection(&APIConfig{ApiKey: &apiKey}); err != nil {
t.Fatalf("CheckConnection: %v", err)
}
}
func TestLongCatListModelsRejectsInvalidResponses(t *testing.T) {
for name, response := range map[string]string{
"missing data": `{}`,
"null data": `{"data":null}`,
"too large": strings.Repeat(" ", longCatMaxListModelsResponseBytes+1),
} {
t.Run(name, func(t *testing.T) {
srv := newLongCatServer(t, "/openai/v1/models", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
_, _ = io.WriteString(w, response)
})
defer srv.Close()
apiKey := "test-key"
_, err := newLongCatForTest(srv.URL).ListModels(&APIConfig{ApiKey: &apiKey})
if err == nil {
t.Fatalf("expected error")
}
})
}
}
func TestLongCatListModelsRequiresAPIKey(t *testing.T) {
for name, cfg := range map[string]*APIConfig{
"nil config": nil,
"nil key": {},
"empty key": {ApiKey: new(string)},
} {
t.Run(name, func(t *testing.T) {
_, err := newLongCatForTest("http://unused").ListModels(cfg)
if err == nil || !strings.Contains(err.Error(), "api key is required") {
t.Errorf("expected api-key error, got %v", err)
}
err = newLongCatForTest("http://unused").CheckConnection(cfg)
if err == nil || !strings.Contains(err.Error(), "api key is required") {
t.Errorf("CheckConnection expected api-key error, got %v", err)
}
})
}
}
func TestLongCatEmbedReturnsNoSuchMethod(t *testing.T) {
m := newLongCatForTest("http://unused")
model := "x"
_, err := m.Embed(&model, []string{"a"}, &APIConfig{}, nil)
if err == nil || !strings.Contains(err.Error(), "no such method") {
t.Errorf("Embed: want 'no such method', got %v", err)
}
}
func TestLongCatRerankReturnsNoSuchMethod(t *testing.T) {
m := newLongCatForTest("http://unused")
model := "x"
_, err := m.Rerank(&model, "q", []string{"a"}, &APIConfig{}, &RerankConfig{TopN: 1})
if err == nil || !strings.Contains(err.Error(), "no such method") {
t.Errorf("Rerank: want 'no such method', got %v", err)
}
}
func TestLongCatBalanceReturnsNoSuchMethod(t *testing.T) {
m := newLongCatForTest("http://unused")
_, err := m.Balance(&APIConfig{})
if err == nil || !strings.Contains(err.Error(), "no such method") {
t.Errorf("Balance: want 'no such method', got %v", err)
}
}
func TestLongCatAudioOCRReturnNoSuchMethod(t *testing.T) {
m := newLongCatForTest("http://unused")
model := "x"
if _, err := m.TranscribeAudio(&model, &model, &APIConfig{}, nil); err == nil || !strings.Contains(err.Error(), "no such method") {
t.Errorf("TranscribeAudio: want 'no such method', got %v", err)
}
if _, err := m.AudioSpeech(&model, &model, &APIConfig{}, nil); err == nil || !strings.Contains(err.Error(), "no such method") {
t.Errorf("AudioSpeech: want 'no such method', got %v", err)
}
if _, err := m.OCRFile(&model, nil, &model, &APIConfig{}, nil); err == nil || !strings.Contains(err.Error(), "no such method") {
t.Errorf("OCRFile: want 'no such method', got %v", err)
}
}