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:
jay77721
2026-07-28 21:08:51 +08:00
committed by GitHub
parent 996c5156e5
commit b02df503ac
2 changed files with 218 additions and 39 deletions

View File

@@ -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

View File

@@ -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,