mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 20:19:24 +08:00
Refactor(go-models)/llm token usage for longcat (#17480)
### Summary Add token usage reporting for the LongCat (Meituan) model provider. Related to #17284. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,31 @@ func (l *LongCatModel) Name() string {
|
||||
return "longcat"
|
||||
}
|
||||
|
||||
// LongCatChatResponse mirrors the OpenAI-compatible response returned by
|
||||
// LongCat's POST /openai/v1/chat/completions endpoint. Usage is always
|
||||
// present on a successful response.
|
||||
type LongCatChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []map[string]any `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// ChatWithMessages sends multiple messages with roles and returns the response.
|
||||
func (l *LongCatModel) ChatWithMessages(ctx context.Context, modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, modelUsage *common.ModelUsage) (*ChatResponse, error) {
|
||||
if err := l.baseModel.APIConfigCheck(apiConfig); err != nil {
|
||||
@@ -102,47 +127,48 @@ func (l *LongCatModel) ChatWithMessages(ctx context.Context, modelName string, m
|
||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err = json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
return parseChatCompletionResponse(body, chatModelConfig, modelUsage, func(body []byte, chatConfig *ChatConfig) (chatResponseParts, error) {
|
||||
var result LongCatChatResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return chatResponseParts{}, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return chatResponseParts{}, fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
choices, ok := result["choices"].([]interface{})
|
||||
if !ok || len(choices) == 0 {
|
||||
return nil, fmt.Errorf("no choices in response")
|
||||
}
|
||||
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.
|
||||
if content == "" && choice.Message.ReasoningContent == "" && len(choice.Message.ToolCalls) == 0 {
|
||||
return chatResponseParts{}, fmt.Errorf("invalid content format")
|
||||
}
|
||||
|
||||
firstChoice, ok := choices[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid choice 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.
|
||||
reasonContent := choice.Message.ReasoningContent
|
||||
if reasonContent != "" && reasonContent[0] == '\n' {
|
||||
reasonContent = reasonContent[1:]
|
||||
}
|
||||
|
||||
messageMap, ok := firstChoice["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid message format")
|
||||
}
|
||||
usage := &TokenUsage{
|
||||
PromptTokens: result.Usage.PromptTokens,
|
||||
CompletionTokens: result.Usage.CompletionTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
}
|
||||
|
||||
content, ok := messageMap["content"].(string)
|
||||
toolCalls := extractToolCalls(messageMap)
|
||||
if !ok && len(toolCalls) == 0 {
|
||||
return nil, fmt.Errorf("invalid content format")
|
||||
}
|
||||
|
||||
// LongCat-Flash-Thinking 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). Pass it through when
|
||||
// present so callers can surface reasoning to the UI. Absent or
|
||||
// non-string means no reasoning was emitted — leave it empty.
|
||||
reasonContent := ""
|
||||
if r, ok := messageMap["reasoning_content"].(string); ok {
|
||||
reasonContent = r
|
||||
}
|
||||
|
||||
return &ChatResponse{
|
||||
Answer: &content,
|
||||
ReasonContent: &reasonContent,
|
||||
ToolCalls: toolCalls,
|
||||
}, nil
|
||||
return chatResponseParts{
|
||||
RequestID: result.ID,
|
||||
Content: &content,
|
||||
ReasonContent: &reasonContent,
|
||||
ToolCalls: choice.Message.ToolCalls,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ChatStreamlyWithSender sends messages and streams the response via the
|
||||
@@ -167,13 +193,14 @@ func (l *LongCatModel) ChatStreamlyWithSender(ctx context.Context, modelName str
|
||||
url := fmt.Sprintf("%s/%s", baseURL, l.baseModel.URLSuffix.Chat)
|
||||
reqBody := buildRequestBody(chatModelConfig, modelName, messages, true)
|
||||
delete(reqBody, "stop")
|
||||
reqBody["stream_options"] = map[string]any{"include_usage": true}
|
||||
|
||||
if chatModelConfig != nil {
|
||||
if chatModelConfig.Stream != nil && !*chatModelConfig.Stream {
|
||||
return fmt.Errorf("stream must be true in ChatStreamlyWithSender")
|
||||
}
|
||||
|
||||
// Only documented fields are forwarded; see ChatWithMessages.
|
||||
chatModelConfig.ToolCallsResult = nil
|
||||
chatModelConfig.UsageResult = nil
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
@@ -208,6 +235,14 @@ func (l *LongCatModel) ChatStreamlyWithSender(ctx context.Context, modelName str
|
||||
return fmt.Errorf("longcat: upstream stream error: %v", apiErr)
|
||||
}
|
||||
|
||||
tokenUsage, found, usageErr := decodeOpenAICompatibleStreamUsage(event)
|
||||
if usageErr != nil {
|
||||
return usageErr
|
||||
}
|
||||
if found {
|
||||
applyStreamUsage(chatModelConfig, modelUsage, tokenUsage)
|
||||
}
|
||||
|
||||
choices, ok := event["choices"].([]interface{})
|
||||
if !ok || len(choices) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -176,6 +176,150 @@ 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.
|
||||
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) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "chatcmpl-abc123",
|
||||
"object": "chat.completion",
|
||||
"created": 1700000000,
|
||||
"model": "LongCat-2.0",
|
||||
"choices": []map[string]interface{}{{
|
||||
"index": 0,
|
||||
"message": map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": "Hello!",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}},
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 15,
|
||||
"total_tokens": 35,
|
||||
},
|
||||
})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
m := newLongCatForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
resp, err := m.ChatWithMessages(ctx, "LongCat-2.0",
|
||||
[]Message{{Role: "user", Content: "ping"}},
|
||||
&APIConfig{ApiKey: &apiKey}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage must be non-nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 20 {
|
||||
t.Errorf("PromptTokens=%d, want 20", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 15 {
|
||||
t.Errorf("CompletionTokens=%d, want 15", resp.Usage.CompletionTokens)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 35 {
|
||||
t.Errorf("TotalTokens=%d, want 35", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongCatChatAcceptsReasoningOnlyResponse verifies that a response with
|
||||
// null content but non-empty reasoning_content is accepted. LongCat's
|
||||
// thinking model can emit all output as reasoning_content, leaving content
|
||||
// null — that is a valid response, not an error.
|
||||
func TestLongCatChatAcceptsReasoningOnlyResponse(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "cmpl_reasoning_only",
|
||||
"object": "chat.completion",
|
||||
"model": "LongCat-2.0",
|
||||
"choices": []map[string]interface{}{{
|
||||
"index": 0,
|
||||
"message": map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": nil,
|
||||
"reasoning_content": "\nThe answer is 4.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}},
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 10,
|
||||
"total_tokens": 15,
|
||||
},
|
||||
})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
m := newLongCatForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
resp, err := m.ChatWithMessages(ctx, "LongCat-2.0",
|
||||
[]Message{{Role: "user", Content: "what is 2+2"}},
|
||||
&APIConfig{ApiKey: &apiKey}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v (should not error on reasoning-only response)", err)
|
||||
}
|
||||
if resp.Answer == nil {
|
||||
t.Error("Answer must be non-nil")
|
||||
} else if *resp.Answer != "" {
|
||||
t.Errorf("Answer=%q, want empty string", *resp.Answer)
|
||||
}
|
||||
if resp.ReasonContent == nil {
|
||||
t.Error("ReasonContent must be non-nil")
|
||||
} else if *resp.ReasonContent != "The answer is 4." {
|
||||
t.Errorf("ReasonContent=%q, want 'The answer is 4.'", *resp.ReasonContent)
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Error("Usage must be non-nil")
|
||||
} else if resp.Usage.TotalTokens != 15 {
|
||||
t.Errorf("Usage=%#v, want total=15", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user