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:
jay77721
2026-07-31 11:38:32 +08:00
committed by GitHub
parent e4c658c046
commit b97969891c
2 changed files with 99 additions and 55 deletions

View File

@@ -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{})

View File

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