mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 21:37:33 +08:00
fix(go-models): align LongCat usage handling with DeepSeek and official docs (#17558)
## Summary - LongCat's token usage did not appear in the server terminal, while DeepSeek's did — even though both drivers parse and record usage through the same shared helpers. The gap was a missing debug log in LongCat's SSE loop, so aggregate usage events were silently processed instead of printed. - The official LongCat API docs and live responses from `api.longcat.chat` return a nested `usage.completion_tokens_details.reasoning_tokens` breakdown for thinking mode. The previous flat `LongCatChatResponse.Usage` struct dropped this field on unmarshal. - Add test coverage for the nested `reasoning_tokens` field on both the non-streaming and streaming paths. Closes #17284
This commit is contained in:
@@ -52,8 +52,7 @@ func (l *LongCatModel) Name() string {
|
||||
}
|
||||
|
||||
// LongCatChatResponse mirrors the OpenAI-compatible response returned by
|
||||
// LongCat's POST /openai/v1/chat/completions endpoint. Usage is always
|
||||
// present on a successful response.
|
||||
// LongCat's POST /openai/v1/chat/completions endpoint.
|
||||
type LongCatChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@@ -70,9 +69,12 @@ type LongCatChatResponse struct {
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
CompletionTokensDetails struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
} `json:"completion_tokens_details"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
@@ -139,17 +141,13 @@ func (l *LongCatModel) ChatWithMessages(ctx context.Context, modelName string, m
|
||||
choice := &result.Choices[0]
|
||||
content := choice.Message.Content
|
||||
// LongCat-Flash-Thinking may emit all output as reasoning_content
|
||||
// with content left null/empty. That is a valid response — only
|
||||
// reject when there is neither content, reasoning, nor tool calls.
|
||||
// with content left empty. That is a valid response — only reject when
|
||||
// there is neither content, reasoning, nor tool calls.
|
||||
if content == "" && choice.Message.ReasoningContent == "" && len(choice.Message.ToolCalls) == 0 {
|
||||
return chatResponseParts{}, fmt.Errorf("invalid content format")
|
||||
}
|
||||
|
||||
// LongCat 2.0 returns the chain-of-thought in a
|
||||
// `reasoning_content` field on the message (OpenAI o-series shape,
|
||||
// also used by kimi-k2.6 and DeepSeek-R1). The field is typically
|
||||
// prefixed with a leading newline — trim it so callers see clean
|
||||
// reasoning. Absent or empty means no reasoning was emitted.
|
||||
// reasoning_content is typically prefixed with a leading newline.
|
||||
reasonContent := choice.Message.ReasoningContent
|
||||
if reasonContent != "" && reasonContent[0] == '\n' {
|
||||
reasonContent = reasonContent[1:]
|
||||
@@ -241,6 +239,9 @@ func (l *LongCatModel) ChatStreamlyWithSender(ctx context.Context, modelName str
|
||||
}
|
||||
if found {
|
||||
applyStreamUsage(chatModelConfig, modelUsage, tokenUsage)
|
||||
// Aggregate counts only — the full event carries content/reasoning_content.
|
||||
common.Info(fmt.Sprintf("longcat: usage prompt=%d completion=%d total=%d",
|
||||
tokenUsage.PromptTokens, tokenUsage.CompletionTokens, tokenUsage.TotalTokens))
|
||||
}
|
||||
|
||||
choices, ok := event["choices"].([]interface{})
|
||||
|
||||
@@ -176,9 +176,9 @@ func TestLongCatChatExtractsReasoningContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongCatChatParsesUsage verifies that the typed LongCat response
|
||||
// parser extracts the OpenAI-compatible usage block into ChatResponse.Usage.
|
||||
// LongCat always returns usage on success, so the field must be populated.
|
||||
// TestLongCatChatParsesUsage verifies that the typed response parser
|
||||
// extracts the OpenAI-compatible usage block, including the nested
|
||||
// completion_tokens_details.reasoning_tokens from LongCat's thinking mode.
|
||||
func TestLongCatChatParsesUsage(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
|
||||
@@ -199,6 +199,9 @@ func TestLongCatChatParsesUsage(t *testing.T) {
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 15,
|
||||
"total_tokens": 35,
|
||||
"completion_tokens_details": map[string]interface{}{
|
||||
"reasoning_tokens": 78,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -280,46 +283,6 @@ func TestLongCatChatAcceptsReasoningOnlyResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongCatStreamRequestsIncludeUsage verifies that the streaming driver
|
||||
// asks LongCat for aggregate token usage via stream_options.include_usage,
|
||||
// and that the usage event populates chatConfig.UsageResult.
|
||||
func TestLongCatStreamRequestsIncludeUsage(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
|
||||
streamOpts, ok := body["stream_options"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("stream_options missing: %v", body)
|
||||
return
|
||||
}
|
||||
if inc, _ := streamOpts["include_usage"].(bool); !inc {
|
||||
t.Errorf("stream_options.include_usage=%v, want true", streamOpts["include_usage"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+
|
||||
`data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`+"\n"+
|
||||
`data: [DONE]`+"\n")
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
m := newLongCatForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
chatConfig := &ChatConfig{}
|
||||
err := m.ChatStreamlyWithSender(ctx, "LongCat-2.0",
|
||||
[]Message{{Role: "user", Content: "x"}},
|
||||
&APIConfig{ApiKey: &apiKey}, chatConfig, nil,
|
||||
func(*string, *string) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
if chatConfig.UsageResult == nil {
|
||||
t.Fatal("UsageResult must be non-nil after stream with usage event")
|
||||
}
|
||||
if chatConfig.UsageResult.PromptTokens != 10 || chatConfig.UsageResult.CompletionTokens != 2 || chatConfig.UsageResult.TotalTokens != 12 {
|
||||
t.Errorf("UsageResult=%#v, want prompt=10 completion=2 total=12", chatConfig.UsageResult)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongCatChatDropsUndocumentedFields guards against re-introducing
|
||||
// stop / reasoning_effort / response_format / tools etc. The LongCat
|
||||
// docs only list model, messages, stream, max_tokens, temperature,
|
||||
@@ -366,6 +329,86 @@ func TestLongCatChatDropsUndocumentedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongCatStreamRequestsIncludeUsage verifies that the streaming driver
|
||||
// requests aggregate usage via stream_options.include_usage and populates
|
||||
// chatConfig.UsageResult from the final usage event.
|
||||
func TestLongCatStreamRequestsIncludeUsage(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
|
||||
streamOpts, ok := body["stream_options"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("stream_options missing: %v", body)
|
||||
return
|
||||
}
|
||||
if inc, _ := streamOpts["include_usage"].(bool); !inc {
|
||||
t.Errorf("stream_options.include_usage=%v, want true", streamOpts["include_usage"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+
|
||||
`data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`+"\n"+
|
||||
`data: [DONE]`+"\n")
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
m := newLongCatForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
chatConfig := &ChatConfig{}
|
||||
err := m.ChatStreamlyWithSender(ctx, "LongCat-2.0",
|
||||
[]Message{{Role: "user", Content: "x"}},
|
||||
&APIConfig{ApiKey: &apiKey}, chatConfig, nil,
|
||||
func(*string, *string) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
if chatConfig.UsageResult == nil {
|
||||
t.Fatal("UsageResult must be non-nil after stream with usage event")
|
||||
}
|
||||
if chatConfig.UsageResult.PromptTokens != 10 || chatConfig.UsageResult.CompletionTokens != 2 || chatConfig.UsageResult.TotalTokens != 12 {
|
||||
t.Errorf("UsageResult=%#v, want prompt=10 completion=2 total=12", chatConfig.UsageResult)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongCatStreamParsesReasoningTokens verifies that a streaming usage
|
||||
// event carrying completion_tokens_details.reasoning_tokens populates
|
||||
// chatConfig.UsageResult.
|
||||
func TestLongCatStreamParsesReasoningTokens(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
|
||||
streamOpts, ok := body["stream_options"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("stream_options missing: %v", body)
|
||||
return
|
||||
}
|
||||
if inc, _ := streamOpts["include_usage"].(bool); !inc {
|
||||
t.Errorf("stream_options.include_usage=%v, want true", streamOpts["include_usage"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+
|
||||
`data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18,"completion_tokens_details":{"reasoning_tokens":5}}}`+"\n"+
|
||||
`data: [DONE]`+"\n")
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
m := newLongCatForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
chatConfig := &ChatConfig{}
|
||||
err := m.ChatStreamlyWithSender(ctx, "LongCat-2.0",
|
||||
[]Message{{Role: "user", Content: "x"}},
|
||||
&APIConfig{ApiKey: &apiKey}, chatConfig, nil,
|
||||
func(*string, *string) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
if chatConfig.UsageResult == nil {
|
||||
t.Fatal("UsageResult must be non-nil after stream with usage event")
|
||||
}
|
||||
if chatConfig.UsageResult.PromptTokens != 10 || chatConfig.UsageResult.CompletionTokens != 8 || chatConfig.UsageResult.TotalTokens != 18 {
|
||||
t.Errorf("UsageResult=%#v, want prompt=10 completion=8 total=18", chatConfig.UsageResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongCatChatRequiresAPIKey(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
m := newLongCatForTest("http://unused")
|
||||
|
||||
Reference in New Issue
Block a user