From b02df503acd6b899acddd61d738113bf3d40af44 Mon Sep 17 00:00:00 2001 From: jay77721 <164177721+jay77721@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:08:51 +0800 Subject: [PATCH] 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 --- internal/entity/models/longcat.go | 113 ++++++++++++------- internal/entity/models/longcat_test.go | 144 +++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 39 deletions(-) diff --git a/internal/entity/models/longcat.go b/internal/entity/models/longcat.go index 2827f3af64..339917a4cf 100644 --- a/internal/entity/models/longcat.go +++ b/internal/entity/models/longcat.go @@ -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 diff --git a/internal/entity/models/longcat_test.go b/internal/entity/models/longcat_test.go index 3d2ffafbf4..c37033241f 100644 --- a/internal/entity/models/longcat_test.go +++ b/internal/entity/models/longcat_test.go @@ -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,